mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-19 12:43:39 +00:00
feat(managed-agent): Improve workspace selection and tool progress (#14405)
* feat(managed-agent): Improve workspace selection and tool progress Allow selecting existing authorized Linux project directories, expose safe tool lifecycle progress, honor the configured model-round limit, and reuse daemon WebShell presentation. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(managed): Allow late model rounds to persist and recover Remove obsolete round ceilings from persistence and continuation validation. Cover late rounds with recovery regressions and complete real-model prompt evidence. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(managed-agent): Order workspace contract imports Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(managed-agent): Correct directory truncation reporting Count an omitted eligible directory before marking the page truncated while preserving the raw-entry scan bound. Pin the unsafe-selection refusal codes and add directory limit regression coverage. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
2746553852
commit
ea6d67390a
53 changed files with 2195 additions and 233 deletions
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/assets/managed-agent-local-debugging/directory-selected.png
Normal file
BIN
docs/assets/managed-agent-local-debugging/directory-selected.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/assets/managed-agent-local-debugging/progress-completed.png
Normal file
BIN
docs/assets/managed-agent-local-debugging/progress-completed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/assets/managed-agent-local-debugging/progress-running.png
Normal file
BIN
docs/assets/managed-agent-local-debugging/progress-running.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
|
|
@ -61,7 +61,7 @@ An invalid `file_path` for Read/Write/Edit is a model-correctable refusal before
|
|||
|
||||
Cancellation aborts inference and requests cancellation of every reserved or started original invocation. A cancellation request is not stop evidence. Only a terminal Runtime result permits result settlement and release. If observation cannot determine the outcome, retain ownership and block. A failed release also blocks further tools rather than clearing ownership locally. An unknown outcome retains the Workspace storage holder, so other Sessions in the same Workspace receive a recoverable busy error until W0e recovery clears that holder.
|
||||
|
||||
The private loop is bounded to 16 model rounds. Broker payloads are limited to 256 KiB, but each argument, result and history resource must also fit the existing 64 KiB inline Session Store limit. Oversized arguments and assistant records are refused before acquisition. For a known terminal execution, check both its serialized outcome and complete tool_result record, including UTF-8, JSON escaping and metadata. If either exceeds the limit, persist a small function error with the original executionStatus and outputOmitted=true instead of the output; read_file responses ask the model to request a smaller offset/limit range. This receipt does not mean the tool failed to execute. It must be committed and consumed before normal settlement and release; unknown outcomes and failed durable writes remain recovery-blocked. Each Broker HTTP request has a 30-second timeout and execution observation is bounded to two minutes plus the current request. These limits are internal to this private profile, not new user settings.
|
||||
The private loop follows the existing `model.maxSessionTurns` setting (default `-1`), including the saved round after recovery; there is no separate 16-round ceiling. Tool intent/receipt metadata is visible without publication, while raw arguments and output retain their publication policy. See the [progress repair](2026-10-16-managed-webshell-progress.md). Broker payloads are limited to 256 KiB, but each argument, result and history resource must also fit the existing 64 KiB inline Session Store limit. Oversized arguments and assistant records are refused before acquisition. For a known terminal execution, check both its serialized outcome and complete tool_result record, including UTF-8, JSON escaping and metadata. If either exceeds the limit, persist a small function error with the original executionStatus and outputOmitted=true instead of the output; read_file responses ask the model to request a smaller offset/limit range. This receipt does not mean the tool failed to execute. It must be committed and consumed before normal settlement and release; unknown outcomes and failed durable writes remain recovery-blocked. Each Broker HTTP request has a 30-second timeout and execution observation is bounded to two minutes plus the current request. These limits are internal to this private profile, not new user settings.
|
||||
|
||||
Cold load keeps the existing refusal of unsettled input. Automatic continuation after process loss, adoption of orphan workers and retired-generation reconciliation remain W0e/#12766/#12670. This slice records enough original identities to support that later work but does not claim it.
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ Read/Write/Edit 的无效 `file_path` 在该批次获取 Workspace 或派发工
|
|||
|
||||
取消会中止推理,并按原始身份请求取消每个已预留或已启动调用。取消请求不等于停止证据;只有 Runtime 终态结果允许结算和释放。无法观察出结果时保留所有权并阻塞。释放失败也阻塞后续工具,不在本地清除所有权。结果未知时仍持有 Workspace 存储,因此同一 Workspace 的其它 Session 会收到可恢复的 busy 错误,直到 W0e 恢复清除原持有者。
|
||||
|
||||
私有循环最多运行 16 轮模型请求。Broker payload 上限为 256 KiB,但每个参数、结果和历史资源也必须满足现有 Session Store 的 64 KiB 内联限制。过大的参数和 assistant 记录在获取所有权前拒绝。对已知终态执行,同时检查序列化 outcome 和完整 tool_result 记录,包括 UTF-8、JSON 转义和元数据;任一超限时,持久化保留原 executionStatus、标记 outputOmitted=true 的小型函数错误,替代原输出,read_file 响应提示模型缩小 offset/limit 范围。这份回执不表示工具未执行。仍须提交并消费回执后才能正常结算和释放;未知结果和持久化失败仍要求恢复并保持阻塞。每个 Broker HTTP 请求超时为 30 秒,执行观察最多两分钟加上当前请求。这些是私有 profile 内部限制,不是新用户设置。
|
||||
私有循环遵循已有的 `model.maxSessionTurns` 设置(默认 `-1`),包括恢复时保存的轮数,不再额外限制为 16 轮。工具 intent/receipt 元数据无需开启发布即可展示,原始参数和输出仍遵循既有发布策略。参见[进度修复](2026-10-16-managed-webshell-progress.zh-CN.md)。Broker payload 上限为 256 KiB,但每个参数、结果和历史资源也必须满足现有 Session Store 的 64 KiB 内联限制。过大的参数和 assistant 记录在获取所有权前拒绝。对已知终态执行,同时检查序列化 outcome 和完整 tool_result 记录,包括 UTF-8、JSON 转义和元数据;任一超限时,持久化保留原 executionStatus、标记 outputOmitted=true 的小型函数错误,替代原输出,read_file 响应提示模型缩小 offset/limit 范围。这份回执不表示工具未执行。仍须提交并消费回执后才能正常结算和释放;未知结果和持久化失败仍要求恢复并保持阻塞。每个 Broker HTTP 请求超时为 30 秒,执行观察最多两分钟加上当前请求。这些是私有 profile 内部限制,不是新用户设置。
|
||||
|
||||
冷 load 保留现有对未结算输入的拒绝。进程丢失后的自动接续、孤儿 worker 接管与旧代执行对账属于 W0e/#12766/#12670。本片记录后续所需的原始身份,但不宣称完成这些能力。
|
||||
|
||||
|
|
|
|||
59
docs/design/2026-10-16-managed-webshell-progress.md
Normal file
59
docs/design/2026-10-16-managed-webshell-progress.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Managed WebShell progress and daemon appearance
|
||||
|
||||
[English](2026-10-16-managed-webshell-progress.md) | [简体中文](2026-10-16-managed-webshell-progress.zh-CN.md)
|
||||
|
||||
Status: implemented and accepted in the local Lima deployment.
|
||||
|
||||
## Problem and scope
|
||||
|
||||
An observed Managed Turn executed 18 tools but exposed no tool events. A silent archive download timed out after 300 seconds, then the private model loop stopped at its hardcoded 16 rounds. WebShell showed only concatenated assistant text and a generic failure. Managed already uses the daemon's MessageList, but does not inherit its customization and adds a large progress box and extra layout gaps.
|
||||
|
||||
This change restores observable tool lifecycle metadata, removes the separate 16-round ceiling in favor of the existing model.maxSessionTurns setting, and aligns the presentation with ordinary daemon WebShell. It does not change execution placement, permissions, tool-result publication policy, or replay tools.
|
||||
|
||||
## Event projection
|
||||
|
||||
The Hosted Harness derives a tool's public identity and name from its committed assistant batch and ordinal. A committed tool.intent produces an in-progress tool_call update; its matching tool.receipt produces the terminal update from the durable execution status. Committed single-call tool_result history also projects a terminal update, covering legacy file execution without a receipt; duplicate status updates converge on the same Item. The model call ID is retained so O3 result projections join the same Item. Both live SSE and transcript replay use the same derivation and original journal sequence. Parallel calls retain separate identities.
|
||||
|
||||
Lifecycle updates contain only the tool ID, name and status. Raw arguments, paths, command contents, output, private references and credentials remain outside this projection. Original content and previews continue through the existing publication and artifact policy. A silent command therefore shows an active tool and elapsed time without inventing output. Missing or unrelated source records do not invent a tool or a terminal status.
|
||||
|
||||
## Model continuation and errors
|
||||
|
||||
The Hosted loop reads the existing model.maxSessionTurns configuration. Its default -1 allows continuation beyond 16 rounds. A positive setting bounds the Turn's model rounds, including the saved round on recovery. The existing cancellation signal, deadline, model token budget and durable recovery barriers remain in force. Limit exhaustion receives a named stop reason and public error code with a fixed, safe message; arbitrary provider errors remain redacted. No new configuration option is introduced.
|
||||
|
||||
Java persistence validation and the CLI's persisted approval and model-request readers accept nonnegative safe-integer round numbers without a separate 16-round ceiling. They preserve the original identity, source activation, digest and authorization checks; the configured limit is enforced by the model loop after restoration. Replacement-owner tests cover both the initial round and the first round beyond the former ceiling. Java tests cover admission beyond that ceiling and rejection of negative, fractional, coerced and unsafe counters.
|
||||
|
||||
## Presentation
|
||||
|
||||
Managed continues using the shared MessageList, MessageItem and ToolGroup. It receives the same presentation customization, without ordinary-daemon ownership callbacks. Its layout removes extra section gaps and passes the Managed Turn's start time to MessageList. The footer uses the daemon StreamingStatus stylesheet with Managed state, rather than reading ordinary daemon stores. Repeated tool progress preserves the first start time. Tools separate consecutive assistant commentary through the existing message adapter. On completion, the final assistant message receives the terminal timestamp so the shared summary retains total duration after replaying snapshot text parts that share their Item's creation time.
|
||||
|
||||
## Affected components and boundaries
|
||||
|
||||
CLI changes affect Hosted event envelopes, metadata derivation, the model loop and continuation validation. Java changes classify the safe limit error and accept late continuation rounds without weakening the existing field allowlist. WebShell changes affect the Managed message adapter, page layout and progress presentation, plus the App customization boundary. There is no schema migration or new API route. Workspace directory selection remains an independent part of this delivery.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
Focused tests cover parallel identities, exact receipt association, successful/failed/cancelled outcomes, absence of raw content, live/replay parity, more than 16 model rounds, positive configured limits and recovery round counting. Consumer tests cover tool timing, message separation, progress presentation and shared customization. Run root build/typecheck/bundle and the affected Java verification gates. Verify the deployed local stack with a disposable real-model Session, a silent Shell, refresh, terminal status and a browser screenshot. Test that publication results still join the metadata Item when publication is enabled; a fixture result alone does not establish live storage acceptance.
|
||||
|
||||
Local acceptance on 2026-10-17 used the existing DeepSeek/deepseek-v4.1-flash configuration. A disposable Session exposed an environment-Hook refusal, a successful 50-second silent Shell and a completed file read as three distinct tool Items, with ordered assistant commentary, terminal state and identical transcript readback. The browser showed Running/elapsed/Waiting for output, then Completed and the final file marker; refresh retained a total duration of 4m36s. A pre-restart disposable Session also continued on the same Runtime worker PID/start identity. The original user Session remained at sequence 148 and was never resubmitted. That initial deployment used controlled tests for more-than-16-round and configured-cap acceptance; live publication storage acceptance remains outside this deployment's metadata-only policy.
|
||||
|
||||
Subsequent isolated acceptance completed 11 Turns across five Sessions using the same model. Independent checks confirmed diagnosis without edits, a rounding repair with nine passing tests and 40 additional arithmetic vectors, and three incremental requirements with preserved history. The first long-chain Turn exposed Java's remaining 0–15 persistence guard after 16 reads and completed after the guards were repaired and the isolated services restarted. A fresh Session then completed without a restart, persisting 25 model requests with rounds 0–24 and Shell approvals at rounds 21 and 23. Its 24 tools read all 20 dependent files, wrote the proof, observed a real verifier failure, corrected the proof and passed verification without another user message. Refresh retained Completed, tool steps and 51s total duration. The original user services and Session were untouched.
|
||||
|
||||
The interrupted task used the existing Runtime tool-result recovery route, which does not emit fresh main-model request metadata. Its completion is recovery evidence; the fresh Session independently establishes uninterrupted continuation beyond 16 rounds. Repeated crashes and a positive configured quota across that legacy route were not tested. Some earlier prompts used extra, individually approved read-only inspection commands despite their narrower Shell list; final model self-reports are not evidence of instruction compliance.
|
||||
|
||||
## Local UI evidence
|
||||
|
||||
The silent command remains visible while running, and the completed transcript retains tool steps and elapsed time after refresh.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
The follow-up screenshots show separate Sessions: the first task blocked after 16 reads, and a fresh task completing 24 tools after the repair. The first test Session also completed after recovery.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Risks and open questions
|
||||
|
||||
Historical public projections are not rewritten, and an already failed Turn is not resumed automatically. Metadata projection must not turn a private result into public content or confuse a Broker execution ID with a model call ID. The existing unlimited default can run longer than the former hardcoded cap; operators can use the existing model limit and Turn deadline. No open product decision blocks this scoped repair.
|
||||
59
docs/design/2026-10-16-managed-webshell-progress.zh-CN.md
Normal file
59
docs/design/2026-10-16-managed-webshell-progress.zh-CN.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Managed WebShell 进度与 daemon 外观对齐
|
||||
|
||||
[English](2026-10-16-managed-webshell-progress.md) | [简体中文](2026-10-16-managed-webshell-progress.zh-CN.md)
|
||||
|
||||
状态:已实现,并通过本地 Lima 部署验收。
|
||||
|
||||
## 问题与范围
|
||||
|
||||
一次实际 Managed Turn 执行了 18 次工具调用,但没有公开任何工具事件。静默下载压缩包的命令在 300 秒后超时,随后私有模型循环在写死的 16 轮处停止。WebShell 只展示拼接的助手文本和通用错误。Managed 已使用 daemon 的 MessageList,但没有继承其展示定制,还额外添加了大的进度框和布局间距。
|
||||
|
||||
本次恢复可观察的工具生命周期元数据,使用已有的 model.maxSessionTurns 设置替代单独的 16 轮上限,并将界面与普通 daemon WebShell 对齐。不改变执行位置、权限和工具结果发布策略,也不重放工具。
|
||||
|
||||
## 事件投影
|
||||
|
||||
Hosted Harness 根据已提交的助手批次与 ordinal 派生工具公开身份和名称。已提交的 tool.intent 生成进行中的 tool_call 更新,匹配的 tool.receipt 根据持久化执行状态生成终态更新。已提交的单调用 tool_result 历史也投影终态更新,覆盖不写 receipt 的旧文件执行路径;重复状态更新合并到同一 Item。保留模型调用 ID,使 O3 结果投影合并到同一个 Item。实时 SSE 和 transcript 回放使用相同派生逻辑及原始日志序号。并行调用保留各自身份。
|
||||
|
||||
生命周期更新只包含工具 ID、名称和状态。原始参数、路径、命令内容、输出、私有引用和凭据均不进入此投影。原始内容与预览仍通过已有发布和产物策略提供。因此静默命令可以显示活动工具及耗时,而不会编造输出。缺少来源或来源不匹配时不编造工具或终态。
|
||||
|
||||
## 模型继续执行与错误
|
||||
|
||||
Hosted 循环读取已有的 model.maxSessionTurns 配置。默认值 -1 允许超过 16 轮继续执行;正数限制单次 Turn 的模型轮数,包括恢复时保存的轮数。现有取消信号、截止时间、模型 token 预算和持久化恢复屏障继续生效。达到配置上限时提供明确的停止原因、公开错误码及固定的安全提示,其他任意模型错误继续脱敏。不增加配置项。
|
||||
|
||||
Java 持久化校验和 CLI 持久化审批、模型请求的读取校验接受非负安全整数轮数,不再单独限制为 16 轮。原有身份、来源 activation、摘要和授权校验全部保留;恢复后的模型循环继续执行配置的上限。替换所有者的测试覆盖初始轮和首次超过旧上限的轮数。Java 测试覆盖超过旧上限后的写入,以及对负数、小数、强制转换类型和不安全计数器的拒绝。
|
||||
|
||||
## 展示
|
||||
|
||||
Managed 继续使用共享的 MessageList、MessageItem 和 ToolGroup。它接收相同的展示定制,但不接入普通 daemon 的所有权回调。布局移除额外的分段间距,并将 Managed Turn 开始时间传给 MessageList。底部状态使用 daemon 的 StreamingStatus 样式表和 Managed 自己的状态,不读取普通 daemon 的 store。重复工具进度保留首次开始时间。工具通过现有消息适配器分隔连续的助手说明。完成时为最后一条助手消息设置终态时间,使共享摘要在回放共用 Item 创建时间的快照文本片段后仍保留总耗时。
|
||||
|
||||
## 影响组件与边界
|
||||
|
||||
CLI 修改涉及 Hosted 事件封装、元数据派生、模型循环和续接校验。Java 修改识别安全的上限错误,并接受后续轮数的续接,不放宽现有字段白名单。WebShell 修改涉及 Managed 消息适配器、页面布局、进度展示以及 App 的展示定制边界。没有数据库迁移或新 API 路由。Workspace 目录选择是本次交付中的独立部分。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
定向测试覆盖并行身份、精确的 receipt 关联、成功/失败/取消结果、原始内容不外泄、实时与回放一致、超过 16 轮、正数配置上限和恢复轮数计数。消费端测试覆盖工具计时、消息分隔、进度展示及共享定制。执行根目录 build/typecheck/bundle 和受影响的 Java 验证门禁。部署到本地后,用一次性真实模型 Session 验证静默 Shell、刷新、终态,并保存浏览器截图。发布开启时验证结果仍合并到元数据 Item;仅有 fixture 结果不能证明真实存储验收。
|
||||
|
||||
2026-10-17 的本地验收沿用已有 DeepSeek/deepseek-v4.1-flash 配置。一次性 Session 将环境 Hook 拒绝、成功运行 50 秒的静默 Shell 和完成的文件读取显示为三个独立工具 Item,助手说明顺序正确,终态和两次 transcript 读取一致。浏览器显示 Running、耗时和 Waiting for output,随后为 Completed 及最终文件 marker;刷新后保留 4m36s 总耗时。重启前的一次性 Session 也在同一 Runtime worker PID/启动身份上继续成功。原用户 Session 仍停留在 sequence 148,从未重提。该初次部署通过受控测试验收超过 16 轮及配置上限;本次部署只发布元数据,未验证真实发布存储。
|
||||
|
||||
后续隔离验收沿用相同模型,五个 Session 共完成 11 个 Turn。独立检查确认:只诊断不改文件;金额舍入修复通过九个测试和额外 40 组金额校验;三轮追加与纠正需求保留历史。首次长链 Turn 在 16 次读取后暴露 Java 遗留的 0–15 轮持久化校验,修复校验并重启隔离服务后继续完成。随后全新 Session 不经重启完成任务,持久化 25 个模型请求,轮数为 0–24,Shell 审批分别位于第 21 和 23 轮。其 24 次工具调用读取全部 20 个依赖文件、写入证明文件、观察真实验证失败、自行修正并通过验证,没有额外用户消息纠偏。刷新后保留 Completed、工具步骤和 51s 总耗时。原用户服务和 Session 均未改动。
|
||||
|
||||
中断任务使用既有 Runtime 工具结果恢复路径,该路径不会产生新的主模型请求元数据。因此它的完成只能证明恢复结果;全新 Session 独立证明不间断执行超过 16 轮。本次没有测试该旧恢复路径上的再次崩溃或正数配置额度。部分前序 prompt 使用了超出其 Shell 白名单的额外只读检查命令,每条均单独审查并单次批准;模型最终自报不能作为遵循指令的证据。
|
||||
|
||||
## 本地界面证据
|
||||
|
||||
静默命令运行时保持可见,已完成的 transcript 在刷新后保留工具步骤和耗时。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
后续截图来自不同 Session:首次任务在 16 次读取后阻塞,修复后全新任务完成 24 次工具调用。首次测试 Session 也已在恢复后完成。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 风险与待确认事项
|
||||
|
||||
不重写历史公开投影,也不自动恢复已经失败的 Turn。元数据投影不能将私有结果变成公开内容,也不能混淆 Broker execution ID 与模型调用 ID。已有的默认不限轮数可能比原先固定上限运行更久;运维仍可通过现有模型上限和 Turn 截止时间约束。本次有限修复没有阻塞实施的产品待定事项。
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Select an existing Managed Workspace directory
|
||||
|
||||
[English](2026-10-16-managed-workspace-directory-selection.md) | [简体中文](2026-10-16-managed-workspace-directory-selection.zh-CN.md)
|
||||
|
||||
Status: Implemented and verified locally on Lima Linux. Production acceptance is outside this change.
|
||||
|
||||
## Problem and current state
|
||||
|
||||
Before this change, New workspace accepted only a name and creates an empty server-owned directory. A user cannot select an existing Linux project. The local debug deployment runs Java and its `local-process` tool workers in Lima Linux, so directory discovery must run there as well.
|
||||
|
||||
## Proposed behavior
|
||||
|
||||
New workspace offers an existing-directory mode alongside empty-directory creation. The user selects an authorized root, browses its real subdirectories, and creates a named Workspace at the chosen project directory. The project stays in place. The selection persists across page reloads and becomes the default directory for new Sessions in that Workspace.
|
||||
|
||||
The selectable roots are existing ACTIVE ROOT Workspaces backed by local storage on which the authenticated actor has OWNER access. An administrator registers a projects parent directory once and grants that root to its owner. The local deployment exposes its Linux projects parent this way. The chosen directory is a working directory, not a new permission boundary: Workspaces within the same storage retain the existing shared file access and physical-owner lease semantics.
|
||||
|
||||
## API and authority
|
||||
|
||||
The WebShell-only `workspaces/directories/query` route accepts `workspaceId` and `cwdRelative`. It returns the canonical authorized root and directory, a bounded list of immediate real child directories, and a truncation flag. The route is persisted-workspace scoped; tenant, actor, ACTIVE ROOT state, OWNER grant, configured mount, native identity, and marker verification all apply before filesystem discovery. Paths are normalized with the existing relative-path contract. Traversal, symlinks, reserved maintenance directories, inaccessible directories, and unsupported storage fail closed. Discovery never falls back to the ordinary daemon. SSH and CSI storage are excluded from local directory candidates and refused before local filesystem resolution; SSH registration keeps its separate node/path check and operation APIs. OpenAPI v1.56 names the local browse request separately from the v1.52 SSH directory-check request.
|
||||
|
||||
The existing create request gains optional `sourceWorkspaceId` and `cwdRelative` fields. Both must be supplied together. Without them, existing empty-directory creation is unchanged. With them, creation verifies the source and directory and publishes a new logical Workspace using that source's storage, configuration, and policy. It neither creates nor registers another physical root. Source identity and generation are pinned in the receipt and checked again before publication. A changed or revoked source cannot complete a pending request.
|
||||
|
||||
The existing actor-scoped WORKSPACE_CREATE receipt persists the selected source, generation, storage, and directory. Its digest covers the normalized request, including source generation when selected. Same-key retries compare with the pinned generation rather than silently rebinding after source changes. Completed replay checks the new Workspace's current access and never restores a grant. No schema or native marker change is needed. Receipt data supplies an optional `defaultCwdRelative` in WebShell summaries; legacy and static Workspaces default to `.`. Public Session-selection semantics remain unchanged.
|
||||
|
||||
## Browser integration
|
||||
|
||||
Reuse the shared directory-browser dialog and its stale-response protection. Directory requests use the Java Managed provider. The browser preserves the pending name, source, directory, and idempotency key before submission. An uncertain operation remains retryable with the exact same request. An explicit abandon-local-confirmation action removes only the persisted browser record and unlocks directory/name editing, including after reload. It warns that a Workspace may already exist and is not deleted; a new creation uses a new key. A browser storage failure preserves the frozen request. Selecting, refreshing, or newly creating a Workspace initializes the composer directory from its persisted default without changing its prompt or model.
|
||||
|
||||
## Affected areas
|
||||
|
||||
| Area | Change |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Java Workspace store and registry | Authorized directory discovery, selected-directory creation, receipt projection |
|
||||
| WebShell API contract | Directory query, optional create fields and default directory, surface gate |
|
||||
| WebShell provider and creator | Directory dialog, pending request persistence, default directory |
|
||||
| Deployment and validation | Register a Linux projects parent; scoped API, UI, and real tool checks |
|
||||
|
||||
## Boundaries and risks
|
||||
|
||||
This change covers the existing trusted Linux local-process deployment. It does not select Mac paths, import arbitrary ungranted server paths, create new machine transports, copy projects, or alter runtime recovery. The storage root remains outside Git worktrees; repositories remain child directories, preserving marker and child-Git lifecycle rules. Administrators must choose a parent whose entire file-access scope is appropriate for its owner. Directory discovery is bounded; it does not recursively scan the filesystem. Receipt and grant transactions must remain correct under concurrent retries and source revocation.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
1. Baseline New workspace has no directory choice; the new API is absent.
|
||||
2. An owner can browse real Linux child directories and create a Workspace that reads the existing project's files with a real model/tool call.
|
||||
3. Refresh, reselect, and a Java-only restart preserve the default project directory and native registration.
|
||||
4. Reader, operator, other actor, other tenant, inactive source, traversal, symlink, maintenance directory, and unconfigured storage requests are refused without disclosing directory contents.
|
||||
5. Same-key and concurrent retries create one Workspace; changed requests conflict; pending source changes fail; completed replay does not regrant access or register held storage.
|
||||
6. Empty creation, model selection, prompt preservation, ordinary daemon behavior, and original Workspace recovery continue to work.
|
||||
|
||||
## Local UI evidence
|
||||
|
||||
The selected Linux project remains the Workspace's default directory after creation and refresh.
|
||||
|
||||

|
||||
|
||||
## Open questions
|
||||
|
||||
None for this scope. Additional selectable project parents are administrator configuration, not inferred from browser-provided paths.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# 选择已有的 Managed Workspace 目录
|
||||
|
||||
[English](2026-10-16-managed-workspace-directory-selection.md) | [简体中文](2026-10-16-managed-workspace-directory-selection.zh-CN.md)
|
||||
|
||||
状态:已在本地实现并通过 Lima Linux 验证,生产验收不属于本次范围。
|
||||
|
||||
## 问题与现状
|
||||
|
||||
本次改动之前,New workspace 只接受名称,并创建由服务端管理的空目录。用户无法选择已有的 Linux 项目。本地调试部署中的 Java 和 `local-process` 工具 worker 都在 Lima Linux 中运行,因此目录发现也必须在该机器上执行。
|
||||
|
||||
## 预期行为
|
||||
|
||||
New workspace 在创建空目录之外增加已有目录模式。用户选择已授权的根目录,浏览其中的真实子目录,再为选中的项目目录创建命名 Workspace。项目保留在原位置。页面刷新后仍保留选择,并将其作为该 Workspace 新 Session 的默认工作目录。
|
||||
|
||||
可选择的根目录是使用本地存储、且当前认证 actor 拥有 OWNER 权限的 ACTIVE ROOT Workspace。管理员先注册项目父目录并向其所有者授权;本地部署也以此方式提供 Linux 项目父目录。所选目录是工作目录,并非新的权限边界:同一 storage 中的 Workspace 继续使用现有的共享文件访问范围和 physical-owner lease 语义。
|
||||
|
||||
## API 与权限
|
||||
|
||||
仅限 WebShell 的 `workspaces/directories/query` 接口接受 `workspaceId` 和 `cwdRelative`,返回已授权的 canonical 根目录和当前目录、有界的直接真实子目录列表,以及截断标志。该接口属于持久 Workspace 范围;读取文件系统前必须检查 tenant、actor、ACTIVE ROOT 状态、OWNER 授权、已配置 mount、native identity 和 marker。路径通过现有相对路径契约规范化。路径穿越、软链接、保留维护目录、不可访问目录和不支持的 storage 均关闭准入。目录发现不会回退到普通 daemon。SSH 和 CSI storage 不会出现在本地目录候选中,并在解析本地文件系统之前被拒绝;SSH 注册继续使用独立的 node/path 检查及操作接口。OpenAPI v1.56 为本地浏览请求使用单独名称,与 v1.52 的 SSH 目录检查请求区分。
|
||||
|
||||
现有创建请求增加可选的 `sourceWorkspaceId` 和 `cwdRelative` 字段,二者必须同时提供。缺省时保持创建空目录的行为。提供时验证源 Workspace 和目录,使用源 storage、配置和策略发布新的逻辑 Workspace,不再创建或注册 physical root。receipt 固定源身份及 generation,并在发布前再次检查。源身份发生变化或权限被撤销后,待完成请求不能继续成功。
|
||||
|
||||
现有 actor 范围的 WORKSPACE_CREATE receipt 持久化所选源、generation、storage 和目录。digest 覆盖规范化请求,选择已有目录时也包括源 generation。同 key 重试与已固定的 generation 比较,不会在源变化后悄悄重新绑定。完成后的重放检查新 Workspace 的当前权限,不会恢复授权。无需修改 schema 或 native marker。WebShell summary 从 receipt 投影可选的 `defaultCwdRelative`;历史和静态 Workspace 默认为 `.`。公开 API 的 Session 选择语义保持不变。
|
||||
|
||||
## 浏览器集成
|
||||
|
||||
复用共享目录浏览对话框及其过期响应保护。目录请求走 Java Managed provider。浏览器在提交前保留待完成请求的名称、源、目录和幂等 key;结果不确定时,用完全相同的请求重试。显式“放弃本地确认”操作仅删除浏览器持久化记录,并恢复目录和名称编辑,刷新后同样可用。界面说明工作区可能已存在,放弃不会删除它;新的创建使用新的 key。浏览器存储操作失败时,保留冻结请求。选择、刷新或新建 Workspace 时,从持久化默认目录初始化输入框,不改变草稿或模型。
|
||||
|
||||
## 涉及范围
|
||||
|
||||
| 层次 | 改动 |
|
||||
| -------------------------------- | -------------------------------------------------- |
|
||||
| Java Workspace store 和 registry | 已授权目录发现、选择目录创建、receipt 投影 |
|
||||
| WebShell API 契约 | 目录查询、可选创建字段和默认目录、surface gate |
|
||||
| WebShell provider 和创建界面 | 目录对话框、待完成请求持久化、默认目录 |
|
||||
| 部署与验证 | 注册 Linux 项目父目录;范围 API、UI 和真实工具验证 |
|
||||
|
||||
## 边界与风险
|
||||
|
||||
本次覆盖已有的可信 Linux local-process 部署,不选择 Mac 路径、不导入任意未授权服务端路径、不增加机器传输、不复制项目,也不修改运行时恢复。storage root 仍位于 Git worktree 之外,仓库位于子目录中,保留 marker 和 child-Git 生命周期规则。管理员必须选择文件访问范围整体适合其所有者的项目父目录。目录发现有界,不递归扫描文件系统。receipt 和授权事务必须在并发重试及源权限撤销时保持正确。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
1. 基线 New workspace 没有目录选择,新 API 不存在。
|
||||
2. OWNER 能浏览真实 Linux 子目录,并创建可以通过真实模型和工具读取已有项目文件的 Workspace。
|
||||
3. 刷新、重新选择和仅重启 Java 后,默认项目目录及 native registration 保持不变。
|
||||
4. READER、OPERATOR、其他 actor、其他 tenant、非 ACTIVE 源、路径穿越、软链接、维护目录和未配置 storage 请求均被拒绝,且不泄露目录内容。
|
||||
5. 相同 key 和并发重试只创建一个 Workspace;请求变化产生冲突;待完成请求遇到源变化失败;完成后的重放不会重新授权或注册正在持有的 storage。
|
||||
6. 创建空目录、模型选择、草稿保留、普通 daemon 行为和原 Workspace 恢复继续正常工作。
|
||||
|
||||
## 本地界面证据
|
||||
|
||||
选择的 Linux 项目在创建和刷新后仍是 Workspace 的默认目录。
|
||||
|
||||

|
||||
|
||||
## 待定事项
|
||||
|
||||
此范围内无待定事项。新增可选项目父目录属于管理员配置,不从浏览器提供的路径推断权限。
|
||||
|
|
@ -29,13 +29,15 @@ afterEach(() => vi.restoreAllMocks());
|
|||
|
||||
it.each(
|
||||
['allow', 'deny', 'cancelled', 'expired'].flatMap((decision) =>
|
||||
[randomUUID(), 'mon_1:notify:3', `arun_${'a'.repeat(32)}:input`].map(
|
||||
(promptId) => ({ decision, promptId }),
|
||||
[0, 16].flatMap((round) =>
|
||||
[randomUUID(), 'mon_1:notify:3', `arun_${'a'.repeat(32)}:input`].map(
|
||||
(promptId) => ({ decision, promptId, round }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)(
|
||||
'restores the original native batch and final Action under a replacement owner ($decision, $promptId)',
|
||||
async ({ decision, promptId }) => {
|
||||
'restores the original native batch and final Action under a replacement owner ($decision, $promptId, round=$round)',
|
||||
async ({ decision, promptId, round }) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'g3-step3-baseline-'));
|
||||
const sessionKey = {
|
||||
tenantId: 'tenant',
|
||||
|
|
@ -160,6 +162,7 @@ it.each(
|
|||
isClientInitiated: false,
|
||||
prompt_id: promptId,
|
||||
};
|
||||
turn.setModelRound(round);
|
||||
running = turn
|
||||
.execute(
|
||||
[call],
|
||||
|
|
@ -232,6 +235,7 @@ it.each(
|
|||
);
|
||||
expect(saved?.plan).toMatchObject({
|
||||
promptId,
|
||||
round,
|
||||
batchId: first.authority
|
||||
.eventsInSequenceRange(1, first.authority.committedSequence)
|
||||
.find(
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ export async function readHostedApprovalContinuation(
|
|||
!isDeepStrictEqual(plan.rootSnapshotRef, header.rootSnapshotRef) ||
|
||||
!Number.isSafeInteger(plan.round) ||
|
||||
plan.round < 0 ||
|
||||
plan.round >= 16 ||
|
||||
!Array.isArray(plan.calls) ||
|
||||
plan.calls.length < 1 ||
|
||||
!Number.isSafeInteger(plan.approvalOrdinal) ||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ import { HostedHookRecoveryRequiredError } from './hosted-hook-session.js';
|
|||
import { SendMessageType } from '@qwen-code/qwen-code-core/core/client.js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { runHostedHarnessTextTurn } from './hosted-harness-model.js';
|
||||
import {
|
||||
HostedModelRoundLimitError,
|
||||
runHostedHarnessTextTurn,
|
||||
} from './hosted-harness-model.js';
|
||||
import { HostedModelUnavailableError } from './hosted-agent-definition.js';
|
||||
import { HostedSkillCatalog } from './hosted-skill-catalog.js';
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core/services/chatRecordingService.js';
|
||||
|
|
@ -110,6 +113,7 @@ function config(
|
|||
sendMessageStream,
|
||||
}),
|
||||
getModel: () => 'test-model',
|
||||
getMaxSessionTurns: () => -1,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
setUserMemory,
|
||||
setPromptToolSnapshot,
|
||||
|
|
@ -163,6 +167,76 @@ function nativeInput(event: HookEventName): HookInput {
|
|||
describe('Hosted Harness model boundary', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it.each([-1, 18])(
|
||||
'continues beyond sixteen rounds with limit %s',
|
||||
async (limit) => {
|
||||
const model = config([]);
|
||||
state.config['getMaxSessionTurns'] = () => limit;
|
||||
let round = 0;
|
||||
let output: Content = { role: 'model', parts: [] };
|
||||
const client = state.config.getLlmClient();
|
||||
state.config.getLlmClient = () => ({
|
||||
...client,
|
||||
getHistory: () => [output],
|
||||
async *sendMessageStream() {
|
||||
round++;
|
||||
if (round <= 17) {
|
||||
output = {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'read_file' } }],
|
||||
};
|
||||
yield {
|
||||
type: LlmEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: `call-${round}`,
|
||||
name: 'read_file',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: input.promptId,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
output = { role: 'model', parts: [{ text: 'done' }] };
|
||||
yield { type: LlmEventType.Content, value: 'done' };
|
||||
}
|
||||
yield { type: LlmEventType.Finished };
|
||||
},
|
||||
});
|
||||
const execute = vi.fn(async () => [{ text: 'file contents' }]);
|
||||
const result = await runHostedHarnessTextTurn({
|
||||
...input,
|
||||
toolTurn: {
|
||||
declarations: async () => [{ name: 'read_file' }],
|
||||
consumeResults: async () => undefined,
|
||||
setPromptHookRunner: () => undefined,
|
||||
execute,
|
||||
},
|
||||
});
|
||||
expect(result.text).toBe('done');
|
||||
expect(round).toBe(18);
|
||||
expect(execute).toHaveBeenCalledTimes(17);
|
||||
expect(model.shutdown).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('honors the configured limit at a recovered model round before sending again', async () => {
|
||||
const model = config([]);
|
||||
state.config['getMaxSessionTurns'] = () => 20;
|
||||
await expect(
|
||||
runHostedHarnessTextTurn({ ...input, initialRound: 20 }),
|
||||
).rejects.toBeInstanceOf(HostedModelRoundLimitError);
|
||||
expect(model.sendMessageStream).not.toHaveBeenCalled();
|
||||
expect(model.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('classifies the native configured session limit without a generic failure', async () => {
|
||||
const model = config([{ type: LlmEventType.MaxSessionTurns }]);
|
||||
await expect(runHostedHarnessTextTurn(input)).rejects.toBeInstanceOf(
|
||||
HostedModelRoundLimitError,
|
||||
);
|
||||
expect(model.shutdown).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('removes local tools before a text model request', async () => {
|
||||
const hooks = config([
|
||||
{ type: LlmEventType.Content, value: 'hello back' },
|
||||
|
|
@ -1358,6 +1432,7 @@ describe('Hosted Harness model boundary', () => {
|
|||
async (resume) => {
|
||||
const hooks = hostedHooks();
|
||||
const model = config([]);
|
||||
state.config['getMaxSessionTurns'] = () => 1;
|
||||
const responses: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
|
|
@ -1401,7 +1476,9 @@ describe('Hosted Harness model boundary', () => {
|
|||
...input,
|
||||
hooks: hooks.session,
|
||||
toolTurn,
|
||||
...(resume ? { resumeFromToolResults: responses } : {}),
|
||||
...(resume
|
||||
? { resumeFromToolResults: responses, initialRound: 1 }
|
||||
: {}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
text: 'Stopped after tools.',
|
||||
|
|
@ -1599,6 +1676,7 @@ describe('Hosted Harness resume and retraction', () => {
|
|||
},
|
||||
}),
|
||||
getModel: () => 'test-model',
|
||||
getMaxSessionTurns: () => -1,
|
||||
getContentGeneratorConfig: () => undefined,
|
||||
setUserMemory: vi.fn(),
|
||||
setPromptToolSnapshot: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ export interface HostedHarnessModelResult {
|
|||
model: string;
|
||||
}
|
||||
|
||||
export class HostedModelRoundLimitError extends Error {
|
||||
constructor() {
|
||||
super('The Hosted Harness Turn reached its configured model round limit.');
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostedHarnessTextDeltas {
|
||||
delta(text: string): Promise<void>;
|
||||
/**
|
||||
|
|
@ -456,19 +462,22 @@ export async function runHostedHarnessTextTurn(input: {
|
|||
let preparedRecovery = input.resumeModel?.prepared;
|
||||
let stopHookActive =
|
||||
(await input.hooks?.wasStopBlocked(input.promptId)) ?? false;
|
||||
const maxRounds = config.getMaxSessionTurns();
|
||||
for (
|
||||
let round = input.resumeModel?.round ?? input.initialRound ?? 0;
|
||||
round < 16;
|
||||
;
|
||||
round++
|
||||
) {
|
||||
input.toolTurn?.setModelRound?.(round);
|
||||
input.signal.throwIfAborted();
|
||||
input.toolTurn?.setModelRound?.(round);
|
||||
if (input.hooks?.hasPendingOperations)
|
||||
throw new HostedHookRecoveryRequiredError();
|
||||
if (input.toolTurn?.hookStopReason) {
|
||||
const text = input.toolTurn.hookStopReason;
|
||||
return { text, parts: [{ text }], model: config.getModel() };
|
||||
}
|
||||
if (maxRounds > 0 && round >= maxRounds)
|
||||
throw new HostedModelRoundLimitError();
|
||||
const declarations =
|
||||
(await input.toolTurn?.declarations(input.signal)) ?? [];
|
||||
client
|
||||
|
|
@ -640,6 +649,9 @@ export async function runHostedHarnessTextTurn(input: {
|
|||
} else if (event.type === LlmEventType.UserCancelled) {
|
||||
modelFailure = false;
|
||||
throw new Error('Hosted Harness turn was cancelled.');
|
||||
} else if (event.type === LlmEventType.MaxSessionTurns) {
|
||||
modelFailure = false;
|
||||
throw new HostedModelRoundLimitError();
|
||||
} else if (
|
||||
event.type !== LlmEventType.ChatCompressed &&
|
||||
event.type !== LlmEventType.Thought &&
|
||||
|
|
@ -737,7 +749,6 @@ export async function runHostedHarnessTextTurn(input: {
|
|||
);
|
||||
pendingToolResults = true;
|
||||
}
|
||||
throw new Error('Hosted tool turn exceeded 16 model rounds.');
|
||||
} finally {
|
||||
input.hooks?.setMessagesProvider(undefined);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -107,7 +107,8 @@ vi.mock(
|
|||
},
|
||||
}),
|
||||
);
|
||||
vi.mock('./hosted-harness-model.js', () => ({
|
||||
vi.mock('./hosted-harness-model.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./hosted-harness-model.js')>()),
|
||||
runHostedHarnessTextTurn: state.model,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ import { teamMemberPreamble } from './hosted-member-chain.js';
|
|||
|
||||
const wakeDeps = vi.hoisted(() => ({
|
||||
last: undefined as unknown,
|
||||
runTurnGate: undefined as Promise<void> | undefined,
|
||||
}));
|
||||
|
||||
const domainEnablement = vi.hoisted(() => ({
|
||||
|
|
@ -203,7 +204,14 @@ vi.mock('./hosted-monitor-wake-turn.js', async (importOriginal) => {
|
|||
...actual,
|
||||
createMonitorWakeRunTurn: (params: never) => {
|
||||
wakeDeps.last = params;
|
||||
return actual.createMonitorWakeRunTurn(params as never);
|
||||
const runTurn = actual.createMonitorWakeRunTurn(params as never);
|
||||
const gate = wakeDeps.runTurnGate;
|
||||
return gate
|
||||
? async (...args: Parameters<typeof runTurn>) => {
|
||||
await gate;
|
||||
return runTurn(...args);
|
||||
}
|
||||
: runTurn;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -337,7 +345,8 @@ vi.mock(
|
|||
},
|
||||
}),
|
||||
);
|
||||
vi.mock('./hosted-harness-model.js', () => ({
|
||||
vi.mock('./hosted-harness-model.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./hosted-harness-model.js')>()),
|
||||
runHostedHarnessTextTurn: state.model,
|
||||
}));
|
||||
|
||||
|
|
@ -418,6 +427,7 @@ function expectFullStreamedAnswer(
|
|||
const listeners = new Set<Server>();
|
||||
|
||||
beforeEach(() => {
|
||||
wakeDeps.runTurnGate = undefined;
|
||||
vi.spyOn(
|
||||
HostedWorkspaceBroker.prototype,
|
||||
'capabilityDigest',
|
||||
|
|
@ -5058,25 +5068,14 @@ describe('Hosted Harness no-tool session', () => {
|
|||
await prewriteAutomationParkedWakeSession();
|
||||
const repair = mockRepairBroker();
|
||||
repair.unlatch();
|
||||
// The pump's first post-load classification hangs at its projection
|
||||
// read: the prompt route's checkpoint probe must run first, while
|
||||
// the Session is still unblocked.
|
||||
const originalProject = ManagedSessionRecordSink.prototype.project;
|
||||
// Hold the pump itself before load: its first projection can otherwise
|
||||
// run before the load response reaches the test, leaving the wrong
|
||||
// consumer suspended at the probe below.
|
||||
let afterLoad = false;
|
||||
let classificationHung = false;
|
||||
let releaseClassify!: () => void;
|
||||
const classifyGate = new Promise<void>((resolve) => {
|
||||
releaseClassify = resolve;
|
||||
let releaseRun!: () => void;
|
||||
wakeDeps.runTurnGate = new Promise<void>((resolve) => {
|
||||
releaseRun = resolve;
|
||||
});
|
||||
vi.spyOn(ManagedSessionRecordSink.prototype, 'project').mockImplementation(
|
||||
function (this: ManagedSessionRecordSink) {
|
||||
if (afterLoad && !classificationHung) {
|
||||
classificationHung = true;
|
||||
return classifyGate.then(() => originalProject.call(this));
|
||||
}
|
||||
return originalProject.call(this);
|
||||
},
|
||||
);
|
||||
// The route probe's checkpoint read hangs next, so the block lands
|
||||
// strictly between the probe's start and its answer.
|
||||
const originalAuthorization =
|
||||
|
|
@ -5126,62 +5125,73 @@ describe('Hosted Harness no-tool session', () => {
|
|||
},
|
||||
);
|
||||
const server = await app(true);
|
||||
const loaded = await headers(
|
||||
supertest(server).post(`/session/${SESSION_ID}/load`),
|
||||
).send({ managedSessionStore: store() });
|
||||
expect(loaded.status).toBe(200);
|
||||
afterLoad = true;
|
||||
const authorize = (request: supertest.Test) =>
|
||||
headers(request).set('X-Qwen-Client-Id', loaded.body.clientId as string);
|
||||
const prompt = [{ type: 'text', text: 'hello' }];
|
||||
const payloadDigest = `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`;
|
||||
// Promise.resolve dispatches the supertest thenable at once (its
|
||||
// request only leaves on .then), so the probe is in flight while
|
||||
// the test drives the pump below.
|
||||
const prompted = Promise.resolve(
|
||||
authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)).send({
|
||||
prompt,
|
||||
promptId: randomUUID(),
|
||||
payloadDigest,
|
||||
}),
|
||||
);
|
||||
// The probe is in flight; the pump now classifies the same park,
|
||||
// and its aftermath stops, commits the cancelled results and parks
|
||||
// at the held release — the block the probe started before.
|
||||
await probed;
|
||||
releaseClassify();
|
||||
await releasing;
|
||||
releaseProbe();
|
||||
const refused = await prompted;
|
||||
// The post-probe re-read, not the probe, held the line: the durable
|
||||
// session-scope code, while the transient-window one belongs to the
|
||||
// probe's own finding (the sibling pin above).
|
||||
expect(refused.status).toBe(409);
|
||||
expect(refused.body.code).toBe('hosted_turn_recovery_required');
|
||||
expect(refused.body.reason).toBe('turn_blocked');
|
||||
// Nothing was admitted.
|
||||
const journal = await LocalJsonlManagedSessionJournalStore.read(
|
||||
path.join(state.root, `${SESSION_ID}.jsonl`),
|
||||
{ tenantId: 'tenant', workspaceId: 'workspace', sessionId: SESSION_ID },
|
||||
);
|
||||
expect(
|
||||
journal.events.some(
|
||||
(event) =>
|
||||
event.kind === 'input.accepted' &&
|
||||
event.payload['source'] === 'hosted-harness',
|
||||
),
|
||||
).toBe(false);
|
||||
releaseRelease();
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const status = await authorize(
|
||||
supertest(server).get(`/session/${SESSION_ID}/status`),
|
||||
try {
|
||||
const loaded = await headers(
|
||||
supertest(server).post(`/session/${SESSION_ID}/load`),
|
||||
).send({ managedSessionStore: store() });
|
||||
expect(loaded.status).toBe(200);
|
||||
afterLoad = true;
|
||||
const authorize = (request: supertest.Test) =>
|
||||
headers(request).set(
|
||||
'X-Qwen-Client-Id',
|
||||
loaded.body.clientId as string,
|
||||
);
|
||||
expect(status.body.recoveryBlocked).toBe(false);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await headers(supertest(server).delete(`/session/${SESSION_ID}`));
|
||||
const prompt = [{ type: 'text', text: 'hello' }];
|
||||
const payloadDigest = `sha256:${createHash('sha256').update(JSON.stringify(prompt)).digest('hex')}`;
|
||||
// Promise.resolve dispatches the supertest thenable at once (its
|
||||
// request only leaves on .then), so the probe is in flight while
|
||||
// the test drives the pump below.
|
||||
const prompted = Promise.resolve(
|
||||
authorize(supertest(server).post(`/session/${SESSION_ID}/prompt`)).send(
|
||||
{
|
||||
prompt,
|
||||
promptId: randomUUID(),
|
||||
payloadDigest,
|
||||
},
|
||||
),
|
||||
);
|
||||
// The probe is in flight; the pump now classifies the same park,
|
||||
// and its aftermath stops, commits the cancelled results and parks
|
||||
// at the held release — the block the probe started before.
|
||||
await probed;
|
||||
releaseRun();
|
||||
await releasing;
|
||||
releaseProbe();
|
||||
const refused = await prompted;
|
||||
// The post-probe re-read, not the probe, held the line: the durable
|
||||
// session-scope code, while the transient-window one belongs to the
|
||||
// probe's own finding (the sibling pin above).
|
||||
expect(refused.status).toBe(409);
|
||||
expect(refused.body.code).toBe('hosted_turn_recovery_required');
|
||||
expect(refused.body.reason).toBe('turn_blocked');
|
||||
// Nothing was admitted.
|
||||
const journal = await LocalJsonlManagedSessionJournalStore.read(
|
||||
path.join(state.root, `${SESSION_ID}.jsonl`),
|
||||
{ tenantId: 'tenant', workspaceId: 'workspace', sessionId: SESSION_ID },
|
||||
);
|
||||
expect(
|
||||
journal.events.some(
|
||||
(event) =>
|
||||
event.kind === 'input.accepted' &&
|
||||
event.payload['source'] === 'hosted-harness',
|
||||
),
|
||||
).toBe(false);
|
||||
releaseRelease();
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const status = await authorize(
|
||||
supertest(server).get(`/session/${SESSION_ID}/status`),
|
||||
);
|
||||
expect(status.body.recoveryBlocked).toBe(false);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await headers(supertest(server).delete(`/session/${SESSION_ID}`));
|
||||
} finally {
|
||||
releaseRun();
|
||||
releaseProbe();
|
||||
releaseRelease();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it('reopens over a queued automation input as idle and runs it', async () => {
|
||||
|
|
|
|||
|
|
@ -135,7 +135,11 @@ import {
|
|||
readHostedAgentInstructions,
|
||||
type HostedAgentDefinition,
|
||||
} from './hosted-agent-definition.js';
|
||||
import { runHostedHarnessTextTurn } from './hosted-harness-model.js';
|
||||
import {
|
||||
HostedModelRoundLimitError,
|
||||
runHostedHarnessTextTurn,
|
||||
} from './hosted-harness-model.js';
|
||||
import { hostedToolProgress } from './hosted-tool-progress.js';
|
||||
import {
|
||||
HostedSkillCatalog,
|
||||
readHostedSkills,
|
||||
|
|
@ -2643,6 +2647,16 @@ async function eventEnvelope(
|
|||
}> {
|
||||
const sessionId =
|
||||
session.managed.authority.sessionHeader.sessionKey.sessionId;
|
||||
const progress = await hostedToolProgress(session.managed, event);
|
||||
if (progress && event.kind !== 'message.committed') {
|
||||
return {
|
||||
v: 1,
|
||||
id: event.sequence,
|
||||
type: 'session_update',
|
||||
promptId: progress.promptId,
|
||||
data: { sessionId, update: progress.update },
|
||||
};
|
||||
}
|
||||
if (event.kind === 'message.delta') {
|
||||
const text = event.payload['text'];
|
||||
const turnId = event.payload['turnId'];
|
||||
|
|
@ -2725,7 +2739,11 @@ async function eventEnvelope(
|
|||
...(message.daemonPromptId
|
||||
? { promptId: message.daemonPromptId }
|
||||
: {}),
|
||||
data: { sessionId, record: message },
|
||||
data: {
|
||||
sessionId,
|
||||
record: message,
|
||||
...(progress ? { update: progress.update } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -2767,6 +2785,7 @@ async function eventEnvelope(
|
|||
event.payload['stopReason'] === 'model_unavailable';
|
||||
const storeUnavailable =
|
||||
event.payload['stopReason'] === 'managed_session_store_unavailable';
|
||||
const roundLimit = event.payload['stopReason'] === 'model_round_limit';
|
||||
return {
|
||||
v: 1,
|
||||
id: event.sequence,
|
||||
|
|
@ -2781,10 +2800,14 @@ async function eventEnvelope(
|
|||
? 'model_unavailable'
|
||||
: storeUnavailable
|
||||
? 'managed_session_store_unavailable'
|
||||
: 'hosted_turn_failed',
|
||||
: roundLimit
|
||||
? 'hosted_turn_limit_exceeded'
|
||||
: 'hosted_turn_failed',
|
||||
message: expired
|
||||
? 'The Hosted Harness Turn exceeded its deadline.'
|
||||
: 'Hosted Harness turn failed.',
|
||||
: roundLimit
|
||||
? 'The Hosted Harness Turn reached its configured model round limit.'
|
||||
: 'Hosted Harness turn failed.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -3317,6 +3340,11 @@ async function executeHostedTurn(
|
|||
cause instanceof HostedModelUnavailableError
|
||||
)
|
||||
stopReason = 'model_unavailable';
|
||||
if (
|
||||
stopReason === 'error' &&
|
||||
cause instanceof HostedModelRoundLimitError
|
||||
)
|
||||
stopReason = 'model_round_limit';
|
||||
if (
|
||||
stopReason === 'error' &&
|
||||
cause instanceof WorkflowStartCancelledError
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ async function readHostedModelRequest(
|
|||
) ||
|
||||
!Number.isSafeInteger(saved.round) ||
|
||||
saved.round < 0 ||
|
||||
saved.round >= 16 ||
|
||||
!Number.isSafeInteger(saved.throughSequence) ||
|
||||
saved.throughSequence < 1 ||
|
||||
saved.throughSequence >= attempt.sequence ||
|
||||
|
|
|
|||
|
|
@ -741,14 +741,17 @@ const prepared: LlmPreparedRequest = {
|
|||
|
||||
it.each(
|
||||
[false, true].flatMap((finished) =>
|
||||
[undefined, false, true].map((allowPersistentRetry) => ({
|
||||
finished,
|
||||
allowPersistentRetry,
|
||||
})),
|
||||
[0, 16].flatMap((round) =>
|
||||
[undefined, false, true].map((allowPersistentRetry) => ({
|
||||
finished,
|
||||
allowPersistentRetry,
|
||||
round,
|
||||
})),
|
||||
),
|
||||
),
|
||||
)(
|
||||
'restores an unfinished request even when stream Finished was recorded (%s)',
|
||||
async ({ finished, allowPersistentRetry }) => {
|
||||
async ({ finished, allowPersistentRetry, round }) => {
|
||||
const f = await fixture();
|
||||
const request = createHostedModelRequest(
|
||||
f.session,
|
||||
|
|
@ -757,7 +760,7 @@ it.each(
|
|||
...prepared,
|
||||
...(allowPersistentRetry === undefined ? {} : { allowPersistentRetry }),
|
||||
},
|
||||
0,
|
||||
round,
|
||||
false,
|
||||
undefined,
|
||||
{ bootId: 'source-boot', eventEpoch: 'source-epoch' },
|
||||
|
|
|
|||
215
packages/cli/src/serve/hosted-tool-progress.test.ts
Normal file
215
packages/cli/src/serve/hosted-tool-progress.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
|
||||
import type {
|
||||
ManagedSessionDurableRef,
|
||||
ManagedSessionEvent,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import { hostedToolProgress } from './hosted-tool-progress.js';
|
||||
|
||||
function fixture(
|
||||
outcome: unknown = { envelope: { executionStatus: 'success' } },
|
||||
) {
|
||||
const bodies = new Map<string, Buffer>();
|
||||
const resource = (id: string, body: unknown) => {
|
||||
const bytes = Buffer.from(JSON.stringify(body));
|
||||
bodies.set(id, bytes);
|
||||
return {
|
||||
resourceId: id,
|
||||
kind: 'managed-message',
|
||||
schemaVersion: 1,
|
||||
byteLength: bytes.length,
|
||||
digest: createHash('sha256').update(bytes).digest('hex'),
|
||||
};
|
||||
};
|
||||
const batchRef = resource('batch-body', {
|
||||
uuid: 'batch',
|
||||
type: 'assistant',
|
||||
daemonPromptId: 'prompt',
|
||||
message: {
|
||||
parts: [
|
||||
{ text: 'Checking the workspace' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'model-call-1',
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'secret command' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
id: 'model-call-2',
|
||||
name: 'read_file',
|
||||
args: { path: '/private/file' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const outcomeRef = resource('outcome-body', {
|
||||
...Object(outcome),
|
||||
secret: 'private output',
|
||||
});
|
||||
const resultRef = resource('result-body', {
|
||||
type: 'tool_result',
|
||||
daemonPromptId: 'prompt',
|
||||
message: {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'model-call-2',
|
||||
name: 'read_file',
|
||||
response: { output: 'private file contents' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const events = [
|
||||
{
|
||||
kind: 'message.committed',
|
||||
sequence: 1,
|
||||
payload: { messageId: 'batch', role: 'assistant', contentRef: batchRef },
|
||||
},
|
||||
{
|
||||
kind: 'tool.intent',
|
||||
sequence: 2,
|
||||
payload: {
|
||||
executionCallId: 'broker-execution-1',
|
||||
batchId: 'batch',
|
||||
ordinal: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'tool.intent',
|
||||
sequence: 3,
|
||||
payload: {
|
||||
executionCallId: 'broker-execution-2',
|
||||
batchId: 'batch',
|
||||
ordinal: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'tool.receipt',
|
||||
sequence: 4,
|
||||
payload: {
|
||||
executionCallId: 'broker-execution-2',
|
||||
toolOutcomeRef: outcomeRef,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'message.committed',
|
||||
sequence: 5,
|
||||
payload: { role: 'tool_result', contentRef: resultRef },
|
||||
},
|
||||
] as unknown as ManagedSessionEvent[];
|
||||
const read = vi.fn(
|
||||
async (ref: ManagedSessionDurableRef) => bodies.get(ref.resourceId)!,
|
||||
);
|
||||
const session = {
|
||||
authority: {
|
||||
eventsInSequenceRange: (from: number, through: number) =>
|
||||
events.filter(
|
||||
(event) => event.sequence >= from && event.sequence <= through,
|
||||
),
|
||||
},
|
||||
resources: { read },
|
||||
} as unknown as Pick<ManagedSession, 'authority' | 'resources'>;
|
||||
return { session, events, read };
|
||||
}
|
||||
|
||||
describe('Hosted tool lifecycle metadata', () => {
|
||||
it('finishes file tools from committed history when the legacy path has no receipt', async () => {
|
||||
const { session, events } = fixture();
|
||||
const progress = await hostedToolProgress(session, events[4]);
|
||||
expect(progress).toEqual({
|
||||
promptId: 'prompt',
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'model-call-2',
|
||||
name: 'read_file',
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(progress)).not.toMatch(/private|contents/);
|
||||
});
|
||||
it('names parallel calls by model IDs and never exposes their arguments or output', async () => {
|
||||
const { session, events } = fixture();
|
||||
const first = await hostedToolProgress(session, events[1]);
|
||||
const second = await hostedToolProgress(session, events[2]);
|
||||
expect(first).toEqual({
|
||||
promptId: 'prompt',
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'model-call-1',
|
||||
name: 'run_shell_command',
|
||||
status: 'in_progress',
|
||||
},
|
||||
});
|
||||
expect(second?.update).toMatchObject({
|
||||
toolCallId: 'model-call-2',
|
||||
name: 'read_file',
|
||||
});
|
||||
const receipt = await hostedToolProgress(session, events[3]);
|
||||
expect(receipt?.update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'model-call-2',
|
||||
name: 'read_file',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(JSON.stringify([first, second, receipt])).not.toMatch(
|
||||
/secret|private|broker-execution/,
|
||||
);
|
||||
expect(await hostedToolProgress(session, events[1])).toEqual(first);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ envelope: { executionStatus: 'error' } }, 'failed'],
|
||||
[{ envelope: { executionStatus: 'cancelled' } }, 'cancelled'],
|
||||
[
|
||||
{ functionResponse: { response: { executionStatus: 'not_started' } } },
|
||||
'failed',
|
||||
],
|
||||
[{ executionStatus: 'success' }, 'completed'],
|
||||
[
|
||||
{ functionResponse: { response: { error: 'redacted failure' } } },
|
||||
'failed',
|
||||
],
|
||||
])('uses durable terminal status for %j', async (outcome, status) => {
|
||||
const { session, events } = fixture(outcome);
|
||||
expect((await hostedToolProgress(session, events[3]))?.update.status).toBe(
|
||||
status,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not invent completion for unknown outcomes or unmatched receipts', async () => {
|
||||
const { session, events } = fixture({
|
||||
envelope: { executionStatus: 'unknown' },
|
||||
});
|
||||
expect(await hostedToolProgress(session, events[3])).toBeUndefined();
|
||||
expect(
|
||||
await hostedToolProgress(session, {
|
||||
...events[3],
|
||||
payload: { ...events[3].payload, executionCallId: 'another-execution' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await hostedToolProgress(session, {
|
||||
...events[1],
|
||||
payload: { ...events[1].payload, ordinal: 9 },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores unrelated events without reading resources', async () => {
|
||||
const { session, events, read } = fixture();
|
||||
expect(await hostedToolProgress(session, events[0])).toBeUndefined();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
165
packages/cli/src/serve/hosted-tool-progress.ts
Normal file
165
packages/cli/src/serve/hosted-tool-progress.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord } from '@qwen-code/qwen-code-core/services/chatRecordingService.js';
|
||||
import type { ManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
|
||||
import {
|
||||
assertManagedSessionDurableRef,
|
||||
type ManagedSessionEvent,
|
||||
} from '@qwen-code/qwen-code-core/managed-runtime/managed-session-records.js';
|
||||
import { readManagedMessageBody } from '@qwen-code/qwen-code-core/managed-runtime/managed-message-chunks.js';
|
||||
|
||||
export async function hostedToolProgress(
|
||||
session: Pick<ManagedSession, 'authority' | 'resources'>,
|
||||
event: ManagedSessionEvent,
|
||||
): Promise<
|
||||
| {
|
||||
promptId: string;
|
||||
update: {
|
||||
sessionUpdate: 'tool_call' | 'tool_call_update';
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
status: 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
if (
|
||||
event.kind === 'message.committed' &&
|
||||
event.payload['role'] === 'tool_result'
|
||||
) {
|
||||
const message = JSON.parse(
|
||||
(
|
||||
await readManagedMessageBody(
|
||||
(ref) => session.resources.read(ref),
|
||||
assertManagedSessionDurableRef(
|
||||
event.payload['contentRef'],
|
||||
'tool result',
|
||||
),
|
||||
)
|
||||
).toString('utf8'),
|
||||
) as ChatRecord;
|
||||
const responses =
|
||||
message.message?.parts?.flatMap((part) =>
|
||||
part.functionResponse ? [part.functionResponse] : [],
|
||||
) ?? [];
|
||||
const response = responses[0];
|
||||
if (
|
||||
responses.length !== 1 ||
|
||||
!response?.id ||
|
||||
!response.name ||
|
||||
!response.response ||
|
||||
!message.daemonPromptId
|
||||
)
|
||||
return;
|
||||
const status = terminalToolStatus(
|
||||
response.response?.['executionStatus'],
|
||||
response.response?.['error'],
|
||||
);
|
||||
if (!status) return;
|
||||
return {
|
||||
promptId: message.daemonPromptId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: response.id,
|
||||
name: response.name,
|
||||
status,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (event.kind !== 'tool.intent' && event.kind !== 'tool.receipt') return;
|
||||
const events = session.authority.eventsInSequenceRange(1, event.sequence);
|
||||
const intent =
|
||||
event.kind === 'tool.intent'
|
||||
? event
|
||||
: events.findLast(
|
||||
(entry) =>
|
||||
entry.kind === 'tool.intent' &&
|
||||
entry.sequence < event.sequence &&
|
||||
entry.payload['executionCallId'] ===
|
||||
event.payload['executionCallId'],
|
||||
);
|
||||
if (!intent) return;
|
||||
const batch = events.find(
|
||||
(entry) =>
|
||||
entry.kind === 'message.committed' &&
|
||||
entry.sequence < intent.sequence &&
|
||||
entry.payload['role'] === 'assistant' &&
|
||||
entry.payload['messageId'] === intent.payload['batchId'],
|
||||
);
|
||||
const ordinal = intent.payload['ordinal'];
|
||||
if (
|
||||
!batch ||
|
||||
typeof ordinal !== 'number' ||
|
||||
!Number.isSafeInteger(ordinal) ||
|
||||
ordinal < 0
|
||||
)
|
||||
return;
|
||||
const message = JSON.parse(
|
||||
(
|
||||
await readManagedMessageBody(
|
||||
(ref) => session.resources.read(ref),
|
||||
assertManagedSessionDurableRef(
|
||||
batch.payload['contentRef'],
|
||||
'tool batch',
|
||||
),
|
||||
)
|
||||
).toString('utf8'),
|
||||
) as ChatRecord;
|
||||
const call = message.message?.parts?.filter((part) => part.functionCall)[
|
||||
ordinal
|
||||
]?.functionCall;
|
||||
if (!call?.id || !call.name || !message.daemonPromptId) return;
|
||||
let status: 'in_progress' | 'completed' | 'failed' | 'cancelled' =
|
||||
'in_progress';
|
||||
if (event.kind === 'tool.receipt') {
|
||||
const outcome = JSON.parse(
|
||||
(
|
||||
await session.resources.read(
|
||||
assertManagedSessionDurableRef(
|
||||
event.payload['toolOutcomeRef'],
|
||||
'tool outcome',
|
||||
),
|
||||
)
|
||||
).toString('utf8'),
|
||||
) as {
|
||||
envelope?: { executionStatus?: string };
|
||||
executionStatus?: string;
|
||||
functionResponse?: {
|
||||
response?: { executionStatus?: string; error?: unknown };
|
||||
};
|
||||
};
|
||||
const response = outcome.functionResponse?.response;
|
||||
const executionStatus =
|
||||
outcome.envelope?.executionStatus ??
|
||||
outcome.executionStatus ??
|
||||
response?.executionStatus;
|
||||
if (executionStatus === undefined && !response) return;
|
||||
const terminal = terminalToolStatus(executionStatus, response?.error);
|
||||
if (!terminal) return;
|
||||
status = terminal;
|
||||
}
|
||||
// Arguments and output remain behind the tool-publication policy. This
|
||||
// lifecycle feed only names the call and its durable execution state.
|
||||
return {
|
||||
promptId: message.daemonPromptId,
|
||||
update: {
|
||||
sessionUpdate:
|
||||
event.kind === 'tool.intent' ? 'tool_call' : 'tool_call_update',
|
||||
toolCallId: call.id,
|
||||
name: call.name,
|
||||
status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function terminalToolStatus(status: unknown, error: unknown) {
|
||||
if (status === 'cancelled') return 'cancelled';
|
||||
if (status === 'success') return 'completed';
|
||||
if (status === 'error' || status === 'not_started') return 'failed';
|
||||
if (status === undefined) return error ? 'failed' : 'completed';
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -623,6 +623,12 @@ Set `QWEN_MANAGED_AGENT_WORKSPACE_CREATION_ROOT` to an existing canonical, serve
|
|||
|
||||
An uncertain response must be retried with the same key and name. The page preserves that pending operation across refresh and selects the result without clearing the prompt or model. Completed replay never restores revoked access, recreates missing registry rows, or registers held storage again. Pending registration resumes in place after a process restart; failed attempts retain their owned directory for retry. There is no automatic storage deletion or arbitrary host-path import. Workspace persistence does not imply recovery of a resident Harness Session whose writer lease expired during control-plane downtime; that existing Session recovery boundary is separate from creating or reusing a Workspace. Keep the configured root and SQL database durable together. Schema V4 extends the receipt namespace after the published V3 Session-list migration while retaining all published migration resources and existing data.
|
||||
|
||||
### Select an existing Linux project directory
|
||||
|
||||
With verified LOCAL storage enabled, **New workspace → Choose existing directory** browses the Linux execution host through Java. Select an ACTIVE ROOT Workspace you own, then choose an existing project beneath its authorized root. Administrators can register and grant a projects parent directory once; the root must remain outside all Git worktrees. The selected project stays in place and becomes the new Workspace's persistent default working directory. Other Workspaces on that storage retain the same shared file-access scope and physical-owner lease; selecting a subdirectory does not create filesystem isolation.
|
||||
|
||||
`POST /api/agent/web-shell/v1/workspaces/directories/query` accepts `workspaceId` and `cwdRelative`, rechecks OWNER access and native storage, and returns at most 100 real immediate child directories (scanning at most 1024 entries). Only this authorized route discloses its Linux root path. The existing create route accepts `sourceWorkspaceId` and `cwdRelative` together to bind a new logical Workspace to the verified source storage, configuration, and policy. It does not create directories or re-register storage. The creation receipt pins source generation and directory; pending retries refuse source changes or revoked ownership. Completed replay checks the new Workspace's current access. WebShell summaries expose `defaultCwdRelative` and `canSelectDirectory`; missing values mean `.` and unsupported, respectively. The page advertises `workspaceDirectorySelection` separately from empty creation and preserves the selected directory across retries, refresh, and reselect. See the [bilingual design](../../docs/design/2026-10-16-managed-workspace-directory-selection.md).
|
||||
|
||||
`QWEN_MANAGED_AGENT_APPROVAL_MODE` defaults to `yolo`. In `default` and
|
||||
`auto-edit`, any caller with a read grant on the bound Workspace can list and
|
||||
inspect pending permission Actions through the public API or WebShell;
|
||||
|
|
@ -1242,9 +1248,14 @@ separate worker bundle exists. The G0 integration test
|
|||
|
||||
The real-model run below creates its Session through the public route as a
|
||||
Workspace-bound G0 Session, and proves the physical tool execution through
|
||||
the durable `qwen_tool_execution` record (exactly one `SETTLED` row) rather
|
||||
than a public `item.tool_call.*` event — Broker-worker tool calls are not
|
||||
published without O2 tool publication. The run needs live model credentials,
|
||||
the durable `ma_tool_execution` record (exactly one `SETTLED` row). Hosted
|
||||
tool intent and receipt events also expose tool names and lifecycle status
|
||||
through public `item.tool_call.updated` events without O2 publication. Raw
|
||||
arguments and output remain behind the existing publication policy. The
|
||||
Hosted model loop follows `model.maxSessionTurns` (default `-1`); it no
|
||||
longer imposes a separate 16-round limit. See the
|
||||
[progress design](../../docs/design/2026-10-16-managed-webshell-progress.md).
|
||||
The run needs live model credentials,
|
||||
so no CI job executes it; it has been run locally as evidence (macOS,
|
||||
qwen3.8-max), and a green CI run therefore says nothing about this mode. The
|
||||
script also needs `java`, `mysqld`, `mysql` and `mysqladmin` on `PATH`; it
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ public class WorkspaceDiscoveryController {
|
|||
.map(WorkspaceDiscoveryController::webShellItem).toList(),
|
||||
page.defaultWorkspace() == null ? null
|
||||
: webShellItem(page.defaultWorkspace()), page.hasMore(),
|
||||
page.nextCursor(), new WebShellCapabilities(true, false, creation.enabled(), true)));
|
||||
page.nextCursor(), new WebShellCapabilities(true, false, creation.enabled(), true,
|
||||
creation.directorySelectionEnabled())));
|
||||
}
|
||||
|
||||
@PostMapping("/api/agent/web-shell/v1/workspaces/get")
|
||||
|
|
@ -75,16 +76,26 @@ public class WorkspaceDiscoveryController {
|
|||
public ResponseEntity<WorkspaceSummary> webShellCreate(TenantContext tenant,
|
||||
@Valid @RequestBody CreateRequest request) {
|
||||
return noStore(webShellItem(creation.create(tenant.tenantId(), tenant.requireActorId(),
|
||||
request.displayName(), request.idempotencyKey())));
|
||||
request.displayName(), request.idempotencyKey(), request.sourceWorkspaceId(), request.cwdRelative())));
|
||||
}
|
||||
|
||||
public record CreateRequest(@NotBlank String displayName, @NotBlank String idempotencyKey) {
|
||||
@PostMapping("/api/agent/web-shell/v1/workspaces/directories/query")
|
||||
public ResponseEntity<WorkspaceCreationStore.DirectoryPage> webShellDirectories(TenantContext tenant,
|
||||
@Valid @RequestBody DirectoryRequest request) {
|
||||
return noStore(creation.directories(tenant.tenantId(), tenant.requireActorId(),
|
||||
request.workspaceId(), request.cwdRelative()));
|
||||
}
|
||||
|
||||
public record CreateRequest(@NotBlank String displayName, @NotBlank String idempotencyKey,
|
||||
String sourceWorkspaceId, String cwdRelative) {
|
||||
@JsonAnySetter
|
||||
public void rejectUnknown(String name, JsonNode value) {
|
||||
throw new IllegalArgumentException("Unknown Workspace creation field");
|
||||
}
|
||||
}
|
||||
|
||||
public record DirectoryRequest(@NotBlank String workspaceId, @NotBlank String cwdRelative) {}
|
||||
|
||||
private static PublicItem publicItem(WorkspaceSummary item) {
|
||||
return new PublicItem(item.workspaceId(), "agent.workspace",
|
||||
item.displayName(),
|
||||
|
|
@ -95,7 +106,7 @@ public class WorkspaceDiscoveryController {
|
|||
private static WorkspaceSummary webShellItem(WorkspaceSummary item) {
|
||||
return new WorkspaceSummary(item.workspaceId(), item.displayName(),
|
||||
item.state().toLowerCase(Locale.ROOT),
|
||||
item.canCreateSession());
|
||||
item.canCreateSession(), item.defaultCwdRelative(), item.canSelectDirectory());
|
||||
}
|
||||
|
||||
private static <T> ResponseEntity<T> noStore(T body) {
|
||||
|
|
@ -110,7 +121,12 @@ public class WorkspaceDiscoveryController {
|
|||
}
|
||||
|
||||
public record WebShellCapabilities(boolean workspaceBinding,
|
||||
boolean workspaceContext, boolean workspaceCreation, boolean approvalModeSelection) {
|
||||
boolean workspaceContext, boolean workspaceCreation, boolean approvalModeSelection,
|
||||
boolean workspaceDirectorySelection) {
|
||||
public WebShellCapabilities(boolean workspaceBinding,
|
||||
boolean workspaceContext, boolean workspaceCreation, boolean approvalModeSelection) {
|
||||
this(workspaceBinding, workspaceContext, workspaceCreation, approvalModeSelection, false);
|
||||
}
|
||||
public WebShellCapabilities(boolean workspaceBinding,
|
||||
boolean workspaceContext, boolean workspaceCreation) {
|
||||
this(workspaceBinding, workspaceContext, workspaceCreation, false);
|
||||
|
|
|
|||
|
|
@ -35,17 +35,25 @@ public class HarnessEventProjector {
|
|||
String code = sourceCode != null
|
||||
&& SAFE_ERROR_CODE.matcher(sourceCode).matches()
|
||||
? sourceCode : "hosted_harness_error";
|
||||
String message = "The Hosted Harness Turn failed.";
|
||||
String message = switch (code) {
|
||||
case "hosted_turn_limit_exceeded" ->
|
||||
"The Hosted Harness Turn reached its configured model round limit.";
|
||||
case "hosted_turn_deadline_exceeded" ->
|
||||
"The Hosted Harness Turn exceeded its deadline.";
|
||||
default -> "The Hosted Harness Turn failed.";
|
||||
};
|
||||
return new ProjectedEvent("turn.failed", Map.of(
|
||||
"code", code, "message", message),
|
||||
true, "FAILED", code, message);
|
||||
}
|
||||
if (!"session_update".equals(event.type())) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> data = object(event.data());
|
||||
Map<String, Object> update = object(data.get("update"));
|
||||
String kind = string(update.get("sessionUpdate"));
|
||||
if (!"session_update".equals(event.type())
|
||||
&& !("managed_journal_event".equals(event.type())
|
||||
&& "tool_call_update".equals(kind))) {
|
||||
return null;
|
||||
}
|
||||
if ("agent_message_chunk".equals(kind)) {
|
||||
return textEvent("item.output_text.delta", "output_text",
|
||||
update, turnId);
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ public final class ManagedHostedRecoveryRecords {
|
|||
ManagedExtensionRecords.closed(source, Set.of("activationId", "epoch"), "source activation");
|
||||
ManagedExtensionRecords.id(source.get("activationId"), "activationId");
|
||||
ManagedExtensionRecords.count(source.get("epoch"), 1, ManagedSessionStoreModels.MAX_SAFE_COUNTER, "source epoch");
|
||||
ManagedExtensionRecords.count(body.get("round"), 0, 15, "round");
|
||||
ManagedExtensionRecords.count(body.get("round"), 0, ManagedSessionStoreModels.MAX_SAFE_COUNTER, "round");
|
||||
for (String field : List.of("definitionRef", "rootSnapshotRef")) {
|
||||
ManagedExtensionRecords.durableRef(body.get(field), field);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
|
|||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Repository
|
||||
public class ManagedWorkspaceRegistry {
|
||||
|
|
@ -123,11 +125,13 @@ public class ManagedWorkspaceRegistry {
|
|||
String actorId, String afterId, int limit) {
|
||||
byte[] key = actorKey(tenantId, actorId);
|
||||
byte[] after = com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys.identity(afterId == null ? "" : afterId);
|
||||
return jdbc.query("SELECT r.workspace_id, r.display_name, r.state, a.role FROM ma_workspace r"
|
||||
List<WorkspaceSummary> rows = jdbc.query("SELECT r.workspace_id, r.display_name, r.state, r.workspace_kind, s.storage_kind, a.role FROM ma_workspace r"
|
||||
+ " JOIN ma_workspace_access a ON a.tenant_id = r.tenant_id AND a.workspace_id = r.workspace_id"
|
||||
+ " LEFT JOIN ma_storage s ON s.tenant_id = r.tenant_id AND s.storage_id = r.storage_id"
|
||||
+ " WHERE r.tenant_id = ? AND a.actor_id = ? AND a.role IN ('READER', 'OPERATOR', 'OWNER')"
|
||||
+ " AND r.workspace_id > ? ORDER BY r.workspace_id LIMIT ?",
|
||||
(result, row) -> summary(result), tenantId, key, after, limit);
|
||||
return withDefaults(tenantId, rows);
|
||||
}
|
||||
|
||||
public WorkspaceSummary findReadable(String tenantId, String actorId,
|
||||
|
|
@ -135,15 +139,16 @@ public class ManagedWorkspaceRegistry {
|
|||
byte[] key = actorKey(tenantId, actorId);
|
||||
List<WorkspaceSummary> rows = jdbc.query(
|
||||
"SELECT r.workspace_id, r.display_name, r.state,"
|
||||
+ " a.role FROM ma_workspace r"
|
||||
+ " r.workspace_kind, s.storage_kind, a.role FROM ma_workspace r"
|
||||
+ " JOIN ma_workspace_access a ON"
|
||||
+ " a.tenant_id = r.tenant_id"
|
||||
+ " AND a.workspace_id = r.workspace_id"
|
||||
+ " LEFT JOIN ma_storage s ON s.tenant_id = r.tenant_id AND s.storage_id = r.storage_id"
|
||||
+ " WHERE r.tenant_id = ? AND r.workspace_id = ?"
|
||||
+ " AND a.actor_id = ? AND a.role IN ('READER',"
|
||||
+ " 'OPERATOR', 'OWNER')",
|
||||
(result, row) -> summary(result), tenantId, workspaceId, key);
|
||||
return rows.isEmpty() ? null : rows.getFirst();
|
||||
return rows.isEmpty() ? null : withDefaults(tenantId, rows).getFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -207,7 +212,34 @@ public class ManagedWorkspaceRegistry {
|
|||
result.getString("display_name"), state,
|
||||
WorkspaceAccess.valueOf(result.getString("role"))
|
||||
.atLeast(WorkspaceAccess.OPERATOR)
|
||||
&& "ACTIVE".equals(state));
|
||||
&& "ACTIVE".equals(state), ".",
|
||||
"OWNER".equals(result.getString("role")) && "ACTIVE".equals(state)
|
||||
&& "ROOT".equals(result.getString("workspace_kind"))
|
||||
&& !"SSH_LOCAL".equals(result.getString("storage_kind"))
|
||||
&& !"CSI".equals(result.getString("storage_kind")));
|
||||
}
|
||||
|
||||
private List<WorkspaceSummary> withDefaults(String tenantId, List<WorkspaceSummary> items) {
|
||||
List<String> keys = items.stream().map(WorkspaceSummary::workspaceId)
|
||||
.filter(id -> id.matches("workspace-[a-f0-9]{64}"))
|
||||
.map(id -> id.substring("workspace-".length())).toList();
|
||||
if (keys.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
List<Object> arguments = new java.util.ArrayList<>();
|
||||
arguments.add(tenantId);
|
||||
arguments.addAll(keys);
|
||||
Map<String, String> defaults = new HashMap<>();
|
||||
jdbc.query("SELECT receipt_key, result_json FROM ma_request_receipt WHERE tenant_id = ?"
|
||||
+ " AND namespace = 'WORKSPACE_CREATE' AND state = 'COMPLETED' AND receipt_key IN ("
|
||||
+ String.join(",", java.util.Collections.nCopies(keys.size(), "?")) + ")",
|
||||
row -> {
|
||||
var details = WorkspaceCreationRoots.details(row.getString("result_json"), row.getString("receipt_key"));
|
||||
defaults.put(details.workspaceId(), details.cwdRelative() == null ? "." : details.cwdRelative());
|
||||
}, arguments.toArray());
|
||||
return items.stream().map(item -> new WorkspaceSummary(item.workspaceId(), item.displayName(),
|
||||
item.state(), item.canCreateSession(), defaults.getOrDefault(item.workspaceId(), "."),
|
||||
item.canSelectDirectory())).toList();
|
||||
}
|
||||
|
||||
private static ReadableGrant readableGrant(ResultSet result)
|
||||
|
|
@ -235,7 +267,10 @@ public class ManagedWorkspaceRegistry {
|
|||
}
|
||||
|
||||
public record WorkspaceSummary(String workspaceId, String displayName,
|
||||
String state, boolean canCreateSession) {
|
||||
String state, boolean canCreateSession, String defaultCwdRelative, boolean canSelectDirectory) {
|
||||
public WorkspaceSummary(String workspaceId, String displayName, String state, boolean canCreateSession) {
|
||||
this(workspaceId, displayName, state, canCreateSession, ".", false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -345,6 +380,16 @@ public class ManagedWorkspaceRegistry {
|
|||
record.getPolicyRef());
|
||||
}
|
||||
|
||||
ResolvedBinding resolveOwnedDirectory(String tenant, String actor, String workspace, String cwd) {
|
||||
ResolvedBinding resolved = resolveForCreation(tenant, actor, new WorkspaceSelection(workspace, cwd));
|
||||
if (accessOf(tenant, actor, workspace) != WorkspaceAccess.OWNER
|
||||
|| jdbc.queryForList("SELECT 1 FROM ma_workspace WHERE tenant_id = ? AND workspace_id = ?"
|
||||
+ " AND workspace_kind = 'ROOT'", Integer.class, tenant, workspace).isEmpty()) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "workspace_not_found", "Workspace not found.");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public static byte[] actorKey(String tenantId, String actorId) {
|
||||
return new WorkspaceActor(tenantId, actorId).getActorId()
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ final class WorkspaceCreationRoots {
|
|||
}
|
||||
}
|
||||
|
||||
Details details(String resultJson, String receiptKey) {
|
||||
static Details details(String resultJson, String receiptKey) {
|
||||
try {
|
||||
Details value = JSON.readValue(resultJson, Details.class);
|
||||
UUID.fromString(value.registrationId());
|
||||
|
|
@ -134,7 +134,13 @@ final class WorkspaceCreationRoots {
|
|||
|| value.displayName().length() > 128
|
||||
|| value.displayName().codePoints().anyMatch(Character::isISOControl)
|
||||
|| !("workspace-" + receiptKey).equals(value.workspaceId())
|
||||
|| !("created-" + receiptKey).equals(value.storageId())) {
|
||||
|| value.sourceWorkspaceId() == null && (!("created-" + receiptKey).equals(value.storageId())
|
||||
|| value.sourceGeneration() != null || value.cwdRelative() != null)
|
||||
|| value.sourceWorkspaceId() != null && (StringUtils.isBlank(value.sourceWorkspaceId())
|
||||
|| value.sourceGeneration() == null || value.sourceGeneration() < 1
|
||||
|| StringUtils.isBlank(value.storageId()) || value.cwdRelative() == null
|
||||
|| !value.cwdRelative().equals(com.alibaba.qwen.code.runtimebroker.managedworkspace
|
||||
.WorkspaceRelativePath.normalize(value.cwdRelative())))) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
return value;
|
||||
|
|
@ -168,5 +174,10 @@ final class WorkspaceCreationRoots {
|
|||
}
|
||||
}
|
||||
|
||||
record Details(String workspaceId, String storageId, String displayName, String registrationId) {}
|
||||
record Details(String workspaceId, String storageId, String displayName, String registrationId,
|
||||
String sourceWorkspaceId, Long sourceGeneration, String cwdRelative) {
|
||||
Details(String workspaceId, String storageId, String displayName, String registrationId) {
|
||||
this(workspaceId, storageId, displayName, registrationId, null, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedWorkspaceRegistry.WorkspaceSummary;
|
||||
import com.alibaba.qwen.code.managedagent.store.WorkspaceCreationRoots.Details;
|
||||
|
|
@ -8,6 +9,7 @@ import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile;
|
|||
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.ReceiptNamespace;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.ReceiptScope;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
|
@ -17,7 +19,14 @@ import org.springframework.stereotype.Repository;
|
|||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -48,7 +57,23 @@ public class WorkspaceCreationStore {
|
|||
}
|
||||
|
||||
public WorkspaceSummary create(String tenant, String actor, String displayName, String key) {
|
||||
if (!enabled()) {
|
||||
return create(tenant, actor, displayName, key, null, null);
|
||||
}
|
||||
|
||||
public boolean directorySelectionEnabled() {
|
||||
return guard.enabled();
|
||||
}
|
||||
|
||||
public WorkspaceSummary create(String tenant, String actor, String displayName, String key,
|
||||
String sourceWorkspaceId, String cwdRelative) {
|
||||
if (sourceWorkspaceId == null && cwdRelative != null
|
||||
|| sourceWorkspaceId != null && cwdRelative == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_request",
|
||||
"Source Workspace and directory must be supplied together.");
|
||||
}
|
||||
WorkspaceSelection selected = sourceWorkspaceId == null ? null
|
||||
: new WorkspaceSelection(sourceWorkspaceId, cwdRelative);
|
||||
if (selected == null ? !enabled() : !directorySelectionEnabled()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "workspace_creation_unavailable",
|
||||
"Workspace creation is not enabled.");
|
||||
}
|
||||
|
|
@ -67,28 +92,35 @@ public class WorkspaceCreationStore {
|
|||
String scope = ManagedReceipts.actorScope(actorKey);
|
||||
String receiptKey = ManagedSchemaKeys.receiptKey(ReceiptNamespace.WORKSPACE_CREATE,
|
||||
tenant, ReceiptScope.ACTOR, scope, key);
|
||||
String digest = ManagedSchemaKeys.key(name);
|
||||
ManagedReceipts.Receipt receipt;
|
||||
try {
|
||||
receipt = transaction.execute(status -> {
|
||||
long now = clock.millis();
|
||||
ManagedReceipts.insert(jdbc, ReceiptNamespace.WORKSPACE_CREATE, tenant,
|
||||
ReceiptScope.ACTOR, scope, key, digest, "CREATE_WORKSPACE", actorKey,
|
||||
null, null, "PENDING", null, null, now);
|
||||
Details details = new Details("workspace-" + receiptKey, "created-" + receiptKey,
|
||||
name, UUID.randomUUID().toString());
|
||||
jdbc.update("UPDATE ma_request_receipt SET result_json = ? WHERE receipt_key = ?",
|
||||
encode(details), receiptKey);
|
||||
return find(tenant, scope, key, true);
|
||||
});
|
||||
} catch (DuplicateKeyException race) {
|
||||
receipt = find(tenant, scope, key, false);
|
||||
if (receipt == null) {
|
||||
throw race;
|
||||
ManagedReceipts.Receipt receipt = find(tenant, scope, key, false);
|
||||
if (receipt == null) {
|
||||
try {
|
||||
receipt = transaction.execute(status -> {
|
||||
long now = clock.millis();
|
||||
ManagedReceipts.insert(jdbc, ReceiptNamespace.WORKSPACE_CREATE, tenant,
|
||||
ReceiptScope.ACTOR, scope, key, ManagedSchemaKeys.key(name), "CREATE_WORKSPACE", actorKey,
|
||||
null, null, "PENDING", null, null, now);
|
||||
var source = selected == null ? null : ownedSource(tenant, actor, selected);
|
||||
Details details = source == null
|
||||
? new Details("workspace-" + receiptKey, "created-" + receiptKey,
|
||||
name, UUID.randomUUID().toString())
|
||||
: new Details("workspace-" + receiptKey, source.binding().getStorageId(), name,
|
||||
UUID.randomUUID().toString(), selected.workspaceId(),
|
||||
source.binding().getWorkspaceGeneration(), selected.cwdRelative());
|
||||
jdbc.update("UPDATE ma_request_receipt SET result_json = ?, request_digest = ? WHERE receipt_key = ?",
|
||||
encode(details), digest(name, selected, details.sourceGeneration()), receiptKey);
|
||||
return find(tenant, scope, key, true);
|
||||
});
|
||||
} catch (DuplicateKeyException race) {
|
||||
receipt = find(tenant, scope, key, false);
|
||||
if (receipt == null) {
|
||||
throw race;
|
||||
}
|
||||
}
|
||||
requireDigest(receipt, digest);
|
||||
}
|
||||
Details details = roots.details(Objects.requireNonNull(receipt).resultJson(), receiptKey);
|
||||
Details details = WorkspaceCreationRoots.details(Objects.requireNonNull(receipt).resultJson(), receiptKey);
|
||||
String digest = digest(name, selected, details.sourceGeneration());
|
||||
requireDigest(receipt, digest);
|
||||
if ("COMPLETED".equals(receipt.state())) {
|
||||
return replay(tenant, actor, details);
|
||||
}
|
||||
|
|
@ -96,13 +128,17 @@ public class WorkspaceCreationStore {
|
|||
throw WorkspaceExecutionStore.unavailable();
|
||||
}
|
||||
try {
|
||||
roots.prepare(tenant, receiptKey);
|
||||
guard.registerCreated(tenant, details.storageId(), details.registrationId());
|
||||
if (selected == null) {
|
||||
roots.prepare(tenant, receiptKey);
|
||||
guard.registerCreated(tenant, details.storageId(), details.registrationId());
|
||||
} else {
|
||||
transaction.executeWithoutResult(status -> verifySource(tenant, actor, details));
|
||||
}
|
||||
} catch (RuntimeException error) {
|
||||
ManagedReceipts.Receipt current = find(tenant, scope, key, false);
|
||||
if (current != null && "COMPLETED".equals(current.state())) {
|
||||
requireDigest(current, digest);
|
||||
return replay(tenant, actor, roots.details(current.resultJson(), receiptKey));
|
||||
return replay(tenant, actor, WorkspaceCreationRoots.details(current.resultJson(), receiptKey));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -116,11 +152,13 @@ public class WorkspaceCreationStore {
|
|||
throw WorkspaceExecutionStore.unavailable();
|
||||
}
|
||||
long now = clock.millis();
|
||||
var source = selected == null ? null : verifySource(tenant, actor, details);
|
||||
jdbc.update("INSERT INTO ma_workspace (tenant_id, workspace_id, workspace_generation,"
|
||||
+ " storage_id, display_name, config_ref, policy_ref, state, gmt_create, gmt_modified)"
|
||||
+ " VALUES (?, ?, 1, ?, ?, ?, ?, 'ACTIVE', ?, ?)",
|
||||
tenant, details.workspaceId(), details.storageId(), details.displayName(),
|
||||
WorkspaceExecutionProfile.CONFIG_REF, WorkspaceExecutionProfile.POLICY_REF, now, now);
|
||||
source == null ? WorkspaceExecutionProfile.CONFIG_REF : source.configRef(),
|
||||
source == null ? WorkspaceExecutionProfile.POLICY_REF : source.policyRef(), now, now);
|
||||
jdbc.update("INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role)"
|
||||
+ " VALUES (?, ?, ?, 'OWNER')", tenant, details.workspaceId(), actorKey);
|
||||
jdbc.update("UPDATE ma_request_receipt SET state = 'COMPLETED', gmt_modified = ?"
|
||||
|
|
@ -129,6 +167,83 @@ public class WorkspaceCreationStore {
|
|||
return replay(tenant, actor, details);
|
||||
}
|
||||
|
||||
public DirectoryPage directories(String tenant, String actor, String workspace, String cwd) {
|
||||
if (!directorySelectionEnabled()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "workspace_creation_unavailable",
|
||||
"Directory selection is not enabled.");
|
||||
}
|
||||
WorkspaceSelection selected = new WorkspaceSelection(workspace, cwd);
|
||||
var source = transaction.execute(status -> ownedSource(tenant, actor, selected));
|
||||
ContextBinding binding = Objects.requireNonNull(source).binding();
|
||||
Path root = guard.verifiedRoot(binding);
|
||||
Path directory = root.resolve(selected.cwdRelative()).normalize();
|
||||
List<Directory> children = new ArrayList<>();
|
||||
boolean truncated = false;
|
||||
try (var entries = Files.newDirectoryStream(directory)) {
|
||||
int scanned = 0;
|
||||
for (Path child : entries) {
|
||||
if (++scanned > 1024) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
String name = child.getFileName().toString();
|
||||
if (!reserved(name) && Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)
|
||||
&& child.equals(child.toRealPath()) && Files.isReadable(child) && Files.isExecutable(child)
|
||||
&& name.codePoints().noneMatch(Character::isISOControl)) {
|
||||
if (children.size() == 100) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
children.add(new Directory(name, root.relativize(child).toString()));
|
||||
}
|
||||
}
|
||||
} catch (IOException error) {
|
||||
throw WorkspaceExecutionStore.unavailable(error);
|
||||
}
|
||||
guard.verify(binding);
|
||||
children.sort(Comparator.comparing(Directory::name));
|
||||
return new DirectoryPage(root.toString(), selected.cwdRelative(), children, truncated);
|
||||
}
|
||||
|
||||
private ManagedWorkspaceRegistry.ResolvedBinding ownedSource(String tenant, String actor,
|
||||
WorkspaceSelection selected) {
|
||||
for (String segment : selected.cwdRelative().split("/")) {
|
||||
if (reserved(segment)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_cwd", "Select a project directory.");
|
||||
}
|
||||
}
|
||||
var source = registry.resolveOwnedDirectory(tenant, actor, selected.workspaceId(), selected.cwdRelative());
|
||||
Path root = guard.verifiedRoot(source.binding());
|
||||
Path directory = root.resolve(selected.cwdRelative()).normalize();
|
||||
if (!Files.isReadable(directory) || !Files.isExecutable(directory)) {
|
||||
throw WorkspaceExecutionStore.unavailable();
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
private ManagedWorkspaceRegistry.ResolvedBinding verifySource(String tenant, String actor, Details details) {
|
||||
var source = ownedSource(tenant, actor,
|
||||
new WorkspaceSelection(details.sourceWorkspaceId(), details.cwdRelative()));
|
||||
if (source.binding().getWorkspaceGeneration() != details.sourceGeneration()
|
||||
|| !source.binding().getStorageId().equals(details.storageId())) {
|
||||
throw WorkspaceExecutionStore.unavailable();
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
private static boolean reserved(String name) {
|
||||
return ".git".equals(name) || ".qwen-child-workspaces".equals(name);
|
||||
}
|
||||
|
||||
private static String digest(String name, WorkspaceSelection selected, Long generation) {
|
||||
return selected == null ? ManagedSchemaKeys.key(name)
|
||||
: ManagedSchemaKeys.key(name, selected.workspaceId(), selected.cwdRelative(), String.valueOf(generation));
|
||||
}
|
||||
|
||||
public record Directory(String name, String cwdRelative) {}
|
||||
|
||||
public record DirectoryPage(String root, String cwdRelative, List<Directory> directories, boolean truncated) {}
|
||||
|
||||
private WorkspaceSummary replay(String tenant, String actor, Details details) {
|
||||
WorkspaceSummary found = registry.findReadable(tenant, actor, details.workspaceId());
|
||||
if (found == null) {
|
||||
|
|
@ -145,7 +260,7 @@ public class WorkspaceCreationStore {
|
|||
private static void requireDigest(ManagedReceipts.Receipt receipt, String digest) {
|
||||
if (!digest.equals(receipt.requestDigest())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "idempotency_conflict",
|
||||
"Idempotency key was already used for another Workspace name.");
|
||||
"Idempotency key was already used for another Workspace request.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,15 @@ public class WorkspaceStorageGuard {
|
|||
return createdRoots.resolve(tenantId, storageId, false);
|
||||
}
|
||||
|
||||
Path verifiedRoot(ContextBinding binding) {
|
||||
if (!enabled) {
|
||||
throw WorkspaceExecutionStore.unavailable();
|
||||
}
|
||||
WorkspaceStorageKindGuard.requireLocalAlias(jdbc, binding.getTenantId(), binding.getStorageId());
|
||||
verify(binding);
|
||||
return root(binding.getTenantId(), binding.getStorageId());
|
||||
}
|
||||
|
||||
void registerCreated(String tenantId, String storageId, String operationId) {
|
||||
Path path = createdRoots.resolve(tenantId, storageId, true);
|
||||
if (path == null) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -145,6 +145,37 @@ class HarnessEventProjectorTest {
|
|||
assertThat(atBound.errorCode()).isEqualTo("b".repeat(128));
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsToolResultLifecycleWithoutPublishingThePrivateRecord() {
|
||||
ProjectedEvent event = projector.project(new SourceEvent(6L,
|
||||
"managed_journal_event", Map.of(
|
||||
"record", Map.of("text", "private output"),
|
||||
"update", Map.of("sessionUpdate", "tool_call_update",
|
||||
"toolCallId", "model-call", "name", "read_file",
|
||||
"status", "completed")),
|
||||
"prompt", Map.of()), "turn-1");
|
||||
|
||||
assertThat(event.type()).isEqualTo("item.tool_call.updated");
|
||||
assertThat(event.data()).containsEntry("toolCallId", "model-call")
|
||||
.containsEntry("status", "completed");
|
||||
assertThat(event.data().toString()).doesNotContain("private output")
|
||||
.doesNotContain("record");
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsConfiguredRoundLimitWithASafeActionableMessage() {
|
||||
ProjectedEvent event = projector.project(new SourceEvent(4L,
|
||||
"turn_error", Map.of("code", "hosted_turn_limit_exceeded",
|
||||
"message", "token=secret path=/private/workspace"),
|
||||
"prompt", Map.of()), "turn-1");
|
||||
|
||||
assertThat(event.errorCode()).isEqualTo("hosted_turn_limit_exceeded");
|
||||
assertThat(event.errorMessage()).isEqualTo(
|
||||
"The Hosted Harness Turn reached its configured model round limit.");
|
||||
assertThat(event.data().toString()).doesNotContain("secret")
|
||||
.doesNotContain("/private/workspace");
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectsDeadlineExpiryAsAClassifiedFailure() {
|
||||
ProjectedEvent event = projector.project(new SourceEvent(4L,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import com.alibaba.qwen.code.managedagent.api.RequestIdFilter;
|
|||
import com.alibaba.qwen.code.managedagent.api.SshWorkspaceController;
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextArgumentResolver;
|
||||
import com.alibaba.qwen.code.managedagent.api.TenantContextFilter;
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceDiscoveryController;
|
||||
import com.alibaba.qwen.code.managedagent.service.SessionRenameCoordinator;
|
||||
import com.alibaba.qwen.code.managedagent.service.SshWorkspaceService;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore;
|
||||
|
|
@ -166,6 +167,7 @@ class ManagedAgentApiContractTest {
|
|||
entry(ApiModels.WebShellArtifactRequest.class, List.of("WebShellArtifactRequest")),
|
||||
entry(ApiModels.WebShellToolResultRequest.class, List.of("WebShellToolResultRequest")),
|
||||
entry(SshWorkspaceController.DirectoryRequest.class, List.of("WebShellWorkspaceDirectoryRequest")),
|
||||
entry(WorkspaceDiscoveryController.DirectoryRequest.class, List.of("WebShellWorkspaceDirectoryQueryRequest")),
|
||||
entry(SshWorkspaceController.CreateRequest.class, List.of("WebShellWorkspaceRegisterRequest")),
|
||||
entry(SshWorkspaceController.OperationRequest.class, List.of("WebShellWorkspaceOperationRequest")),
|
||||
entry(SshWorkspaceService.NodeSummary.class, List.of("WebShellRuntimeNode")),
|
||||
|
|
@ -1189,6 +1191,10 @@ class ManagedAgentApiContractTest {
|
|||
post(WEB_SHELL + "/workspaces/create")
|
||||
.header(TENANT, workspaceTenant).principal(actor),
|
||||
"{\"displayName\":\"New workspace\",\"idempotencyKey\":\"workspace-create\"}");
|
||||
exchange(drift, "queryWebShellWorkspaceDirectories", 409,
|
||||
post(WEB_SHELL + "/workspaces/directories/query")
|
||||
.header(TENANT, workspaceTenant).principal(actor),
|
||||
"{\"workspaceId\":\"ws-default\",\"cwdRelative\":\".\"}");
|
||||
String publicBoundId = json(exchange(drift, "createSession", 202,
|
||||
post("/v1/agents/sessions").header(TENANT, workspaceTenant)
|
||||
.principal(actor).header(IDEMPOTENCY_KEY, "bound-public"),
|
||||
|
|
|
|||
|
|
@ -1608,6 +1608,7 @@ class SurfaceAdmissionAcceptanceTest {
|
|||
+ "\",\"artifactId\":\"" + artifactId + "\"}";
|
||||
case ARTIFACT_LIST -> "{\"sessionId\":\"" + session + "\"}";
|
||||
case WORKSPACE_CREATE -> "{\"displayName\":\"New workspace\",\"idempotencyKey\":\"create-key\"}";
|
||||
case WORKSPACE_DIRECTORIES -> "{\"workspaceId\":\"workspace\",\"cwdRelative\":\".\"}";
|
||||
case SESSION_OPTIONS -> "{\"agentId\":\"qwen-code\"}";
|
||||
case SESSION_APPROVAL, SESSION_MODELS -> "{\"sessionId\":\"" + session + "\"}";
|
||||
case SESSION_APPROVAL_CHANGE -> "{\"sessionId\":\"" + session
|
||||
|
|
|
|||
|
|
@ -297,6 +297,10 @@ public enum SurfaceRegistry {
|
|||
"/api/agent/web-shell/v1/workspaces/create",
|
||||
Surface.WEBSHELL, RuleClass.WORKSPACE_ALLOCATION,
|
||||
EnumSet.of(Capability.WORKSPACE_CREATE)),
|
||||
WEBSHELL_WORKSPACE_DIRECTORIES(Method.POST,
|
||||
"/api/agent/web-shell/v1/workspaces/directories/query",
|
||||
Surface.WEBSHELL, RuleClass.WORKSPACE_ALLOCATION,
|
||||
EnumSet.of(Capability.WORKSPACE_DIRECTORIES)),
|
||||
WEBSHELL_SESSION_OPTIONS(Method.POST,
|
||||
"/api/agent/web-shell/v1/session-options/query",
|
||||
Surface.WEBSHELL, RuleClass.SESSION_OPTIONS,
|
||||
|
|
@ -558,6 +562,7 @@ public enum SurfaceRegistry {
|
|||
ARTIFACT_GET,
|
||||
ARTIFACT_CONTENT,
|
||||
WORKSPACE_CREATE,
|
||||
WORKSPACE_DIRECTORIES,
|
||||
SESSION_OPTIONS,
|
||||
SESSION_APPROVAL,
|
||||
SESSION_APPROVAL_CHANGE,
|
||||
|
|
@ -712,7 +717,7 @@ public enum SurfaceRegistry {
|
|||
* Workspace is invisible.
|
||||
*/
|
||||
WORKSPACE_DISCOVERY,
|
||||
/** A trusted tenant actor allocates a new root only when operator configuration enables it. */
|
||||
/** A trusted actor allocates an empty root, or selects an existing directory with source ROOT ownership. */
|
||||
WORKSPACE_ALLOCATION,
|
||||
/** A trusted tenant actor may inspect effective creation policy for a tenant Agent. */
|
||||
SESSION_OPTIONS,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.alibaba.qwen.code.managedagent.store;
|
|||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
|
|
@ -167,6 +169,23 @@ class ManagedHostedRecoveryRecordsTest {
|
|||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(longs = {0, 15, 16, 20, ManagedSessionStoreModels.MAX_SAFE_COUNTER})
|
||||
void acceptsNonnegativeSafeModelRoundCounters(long round) {
|
||||
assertThatCode(() -> validateModel(modelRequest().put("round", round))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidModelRoundCounters() {
|
||||
for (var value : java.util.List.of(json.getNodeFactory().numberNode(-1),
|
||||
json.getNodeFactory().numberNode(1.5), json.getNodeFactory().textNode("16"),
|
||||
json.getNodeFactory().numberNode(ManagedSessionStoreModels.MAX_SAFE_COUNTER + 1))) {
|
||||
ObjectNode body = modelRequest();
|
||||
body.set("round", value);
|
||||
assertThatThrownBy(() -> validateModel(body)).hasMessageContaining("round");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidPreparedRetryTypesAndUnknownFields() {
|
||||
for (var value : java.util.List.of(json.getNodeFactory().nullNode(), json.getNodeFactory().textNode("true"),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.alibaba.qwen.code.managedagent.store;
|
|||
import com.alibaba.qwen.code.managedagent.api.ApiException;
|
||||
import com.alibaba.qwen.code.managedagent.api.WorkspaceSelection;
|
||||
import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties;
|
||||
import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema;
|
||||
import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
@ -206,6 +207,164 @@ class WorkspaceCreationStoreTest {
|
|||
return jdbc.queryForObject("SELECT storage_id FROM ma_workspace WHERE workspace_id = ?", String.class, workspace);
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectsExistingProjectAndPersistsItsDefaultWithoutRegisteringStorageAgain() throws Exception {
|
||||
var parent = store(guard).create("tenant", "actor", "Projects", "projects");
|
||||
Path root = guard.createdStorageRoot("tenant", storage(parent.workspaceId()));
|
||||
Path project = Files.createDirectory(root.resolve("existing-project"));
|
||||
Files.writeString(project.resolve("README.md"), "existing project");
|
||||
var before = jdbc.queryForMap("SELECT * FROM ma_storage_owner");
|
||||
jdbc.update("UPDATE ma_storage_owner SET holder_key = 'existing-runtime'");
|
||||
var created = store(guard).create("tenant", "actor", "Project", "selected",
|
||||
parent.workspaceId(), "./existing-project/");
|
||||
assertThat(created.defaultCwdRelative()).isEqualTo("existing-project");
|
||||
assertThat(storage(created.workspaceId())).isEqualTo(storage(parent.workspaceId()));
|
||||
assertThat(Files.readString(project.resolve("README.md"))).isEqualTo("existing project");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_storage_owner", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForMap("SELECT * FROM ma_storage_owner")).containsEntry("holder_key", "existing-runtime")
|
||||
.containsEntry("mount_registration_id", before.get("mount_registration_id"));
|
||||
assertThat(new ManagedWorkspaceRegistry(jdbc).findReadable("tenant", "actor", created.workspaceId())
|
||||
.defaultCwdRelative()).isEqualTo("existing-project");
|
||||
assertThat(store(guard).create("tenant", "actor", "Project", "selected", parent.workspaceId(),
|
||||
"existing-project")).isEqualTo(created);
|
||||
assertThatThrownBy(() -> store(guard).create("tenant", "actor", "Project", "selected",
|
||||
parent.workspaceId(), ".")).isInstanceOfSatisfying(ApiException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo("idempotency_conflict"));
|
||||
jdbc.update("DELETE FROM ma_workspace_access WHERE workspace_id = ?", parent.workspaceId());
|
||||
assertThat(store(guard).create("tenant", "actor", "Project", "selected", parent.workspaceId(),
|
||||
"existing-project")).isEqualTo(created);
|
||||
jdbc.update("DELETE FROM ma_workspace_access WHERE workspace_id = ?", created.workspaceId());
|
||||
assertThatThrownBy(() -> store(guard).create("tenant", "actor", "Project", "selected",
|
||||
parent.workspaceId(), "existing-project")).isInstanceOf(ApiException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void browsesOnlyOwnedRealDirectoriesAndRefusesUnsafeSelections() throws Exception {
|
||||
var parent = store(guard).create("tenant", "actor", "Projects", "browse");
|
||||
Path root = guard.createdStorageRoot("tenant", storage(parent.workspaceId()));
|
||||
Files.createDirectory(root.resolve("project"));
|
||||
Files.createDirectory(root.resolve(".git"));
|
||||
Files.createDirectory(root.resolve(".qwen-child-workspaces"));
|
||||
Files.createSymbolicLink(root.resolve("outside"), temp);
|
||||
var page = store(guard).directories("tenant", "actor", parent.workspaceId(), ".");
|
||||
assertThat(page.root()).isEqualTo(root.toString());
|
||||
assertThat(page.directories()).extracting(WorkspaceCreationStore.Directory::name).containsExactly("project");
|
||||
for (String path : List.of("../", "outside", ".git", ".qwen-child-workspaces")) {
|
||||
String code = "outside".equals(path) ? "workspace_unavailable" : "invalid_cwd";
|
||||
assertThatThrownBy(() -> store(guard).directories("tenant", "actor", parent.workspaceId(), path))
|
||||
.isInstanceOfAny(ApiException.class, RuntimeBrokerException.class)
|
||||
.hasFieldOrPropertyWithValue("code", code);
|
||||
assertThatThrownBy(() -> store(guard).create("tenant", "actor", "Unsafe", "unsafe-" + path,
|
||||
parent.workspaceId(), path)).isInstanceOfAny(ApiException.class, RuntimeBrokerException.class)
|
||||
.hasFieldOrPropertyWithValue("code", code);
|
||||
}
|
||||
assertThatThrownBy(() -> store(guard).directories("other", "actor", parent.workspaceId(), "."))
|
||||
.isInstanceOf(ApiException.class);
|
||||
assertThatThrownBy(() -> store(guard).directories("tenant", "other", parent.workspaceId(), "."))
|
||||
.isInstanceOf(ApiException.class);
|
||||
for (String role : List.of("READER", "OPERATOR")) {
|
||||
jdbc.update("UPDATE ma_workspace_access SET role = ?", role);
|
||||
assertThatThrownBy(() -> store(guard).directories("tenant", "actor", parent.workspaceId(), "."))
|
||||
.isInstanceOf(ApiException.class);
|
||||
assertThatThrownBy(() -> store(guard).create("tenant", "actor", "Refused", "role-" + role,
|
||||
parent.workspaceId(), "project")).isInstanceOf(ApiException.class);
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_workspace", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatesOnlyWhenAnotherEligibleDirectoryRemains() throws Exception {
|
||||
var parent = store(guard).create("tenant", "actor", "Projects", "directory-limit");
|
||||
Path root = guard.createdStorageRoot("tenant", storage(parent.workspaceId()));
|
||||
for (int index = 0; index < 100; index++) {
|
||||
Files.createDirectory(root.resolve("project-" + index));
|
||||
Files.writeString(root.resolve("ignored-" + index), "not a directory");
|
||||
}
|
||||
var complete = store(guard).directories("tenant", "actor", parent.workspaceId(), ".");
|
||||
assertThat(complete.directories()).hasSize(100);
|
||||
assertThat(complete.truncated()).isFalse();
|
||||
Files.createDirectory(root.resolve("one-more-project"));
|
||||
var limited = store(guard).directories("tenant", "actor", parent.workspaceId(), ".");
|
||||
assertThat(limited.directories()).hasSize(100);
|
||||
assertThat(limited.truncated()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesTheRawEntryScanBudgetWhenEntriesAreFilteredOut() throws Exception {
|
||||
var parent = store(guard).create("tenant", "actor", "Projects", "directory-scan-limit");
|
||||
Path root = guard.createdStorageRoot("tenant", storage(parent.workspaceId()));
|
||||
for (int index = 0; index < 1025; index++) {
|
||||
Files.writeString(root.resolve("ignored-" + index), "not a directory");
|
||||
}
|
||||
var page = store(guard).directories("tenant", "actor", parent.workspaceId(), ".");
|
||||
assertThat(page.directories()).isEmpty();
|
||||
assertThat(page.truncated()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void localDirectorySelectionRefusesRemoteStorageEvenIfGeneralVerificationSucceeds() {
|
||||
var parent = store(guard).create("tenant", "actor", "Projects", "remote-parent");
|
||||
assertThat(parent.canSelectDirectory()).isTrue();
|
||||
jdbc.update("UPDATE ma_storage SET storage_kind = 'SSH_LOCAL' WHERE alias_key = ?",
|
||||
WorkspaceCsiRegistration.aliasKey("tenant", storage(parent.workspaceId())));
|
||||
assertThat(registry.findReadable("tenant", "actor", parent.workspaceId()).canSelectDirectory()).isFalse();
|
||||
assertThat(registry.listReadable("tenant", "actor", null, 10))
|
||||
.allSatisfy(item -> assertThat(item.canSelectDirectory()).isFalse());
|
||||
var remoteAware = spy(guard);
|
||||
org.mockito.Mockito.doNothing().when(remoteAware).verify(org.mockito.ArgumentMatchers.any());
|
||||
assertThatThrownBy(() -> remoteAware.verifiedRoot(binding(parent.workspaceId())))
|
||||
.isInstanceOfSatisfying(RuntimeBrokerException.class,
|
||||
error -> assertThat(error.getCode()).isEqualTo("workspace_unavailable"));
|
||||
assertThatThrownBy(() -> store(guard).directories("tenant", "actor", parent.workspaceId(), "."))
|
||||
.isInstanceOf(ApiException.class);
|
||||
assertThatThrownBy(() -> store(guard).create("tenant", "actor", "Remote", "remote-selection",
|
||||
parent.workspaceId(), ".")).isInstanceOf(ApiException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_workspace", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingSelectionCannotRebindAfterSourceGenerationChanges() throws Exception {
|
||||
var parent = store(guard).create("tenant", "actor", "Projects", "pending-parent");
|
||||
Path root = guard.createdStorageRoot("tenant", storage(parent.workspaceId()));
|
||||
Files.createDirectory(root.resolve("project"));
|
||||
var interrupted = spy(guard);
|
||||
var count = new java.util.concurrent.atomic.AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
if (count.incrementAndGet() == 2) {
|
||||
throw new IllegalStateException("interrupted after receipt");
|
||||
}
|
||||
return invocation.callRealMethod();
|
||||
}).when(interrupted).verifiedRoot(org.mockito.ArgumentMatchers.any());
|
||||
assertThatThrownBy(() -> store(interrupted).create("tenant", "actor", "Project", "pending-selection",
|
||||
parent.workspaceId(), "project")).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_request_receipt WHERE state = 'PENDING'", Integer.class))
|
||||
.isEqualTo(1);
|
||||
jdbc.update("UPDATE ma_workspace SET workspace_generation = 2 WHERE workspace_id = ?", parent.workspaceId());
|
||||
assertThatThrownBy(() -> store(guard).create("tenant", "actor", "Project", "pending-selection",
|
||||
parent.workspaceId(), "project")).isInstanceOf(RuntimeException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_workspace", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentSelectionPublishesOneWorkspace() throws Exception {
|
||||
var parent = store(guard).create("tenant", "actor", "Projects", "concurrent-parent");
|
||||
Path root = guard.createdStorageRoot("tenant", storage(parent.workspaceId()));
|
||||
Files.createDirectory(root.resolve("project"));
|
||||
var barrier = new CyclicBarrier(2);
|
||||
try (var pool = Executors.newFixedThreadPool(2)) {
|
||||
java.util.concurrent.Callable<ManagedWorkspaceRegistry.WorkspaceSummary> action = () -> {
|
||||
barrier.await();
|
||||
return store(guard).create("tenant", "actor", "Project", "concurrent-project",
|
||||
parent.workspaceId(), "project");
|
||||
};
|
||||
var first = pool.submit(action);
|
||||
var second = pool.submit(action);
|
||||
assertThat(first.get(20, TimeUnit.SECONDS)).isEqualTo(second.get(20, TimeUnit.SECONDS));
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_workspace", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_storage_owner", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
private ContextBinding binding(String workspace) {
|
||||
return new ContextBinding("tenant", workspace, 1, storage(workspace), ".", "config", 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
class WorkspaceDirectorySelectionMySqlIT extends WorkspaceCreationStoreTest {
|
||||
private IsolatedManagedSchemaMySqlTestDatabase database;
|
||||
|
||||
@Override
|
||||
protected DataSource dataSource() {
|
||||
database = new IsolatedManagedSchemaMySqlTestDatabase();
|
||||
return database.source();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void closeDatabase() {
|
||||
if (database != null) {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22710,25 +22710,27 @@ export function App({
|
|||
aria-hidden={!managedChatVisible || undefined}
|
||||
data-testid="managed-home"
|
||||
>
|
||||
<ManagedSessionsPage
|
||||
key={managedAgentProvider.storageKey}
|
||||
sessionId={managedSessionId}
|
||||
onSelectSession={(id) => {
|
||||
setManagedSessionId(id);
|
||||
if (managedModeRef.current) {
|
||||
closeMobileDrawer();
|
||||
returnToChat();
|
||||
<WebShellCustomizationProvider value={customization}>
|
||||
<ManagedSessionsPage
|
||||
key={managedAgentProvider.storageKey}
|
||||
sessionId={managedSessionId}
|
||||
onSelectSession={(id) => {
|
||||
setManagedSessionId(id);
|
||||
if (managedModeRef.current) {
|
||||
closeMobileDrawer();
|
||||
returnToChat();
|
||||
}
|
||||
}}
|
||||
workspaceCwd={lockedWorkspaceCwd}
|
||||
managedAgentProvider={managedAgentProvider}
|
||||
interactive={managedChatVisible}
|
||||
navigationTarget={
|
||||
sidebarOptions.enabled
|
||||
? managedNavigationTarget
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
workspaceCwd={lockedWorkspaceCwd}
|
||||
managedAgentProvider={managedAgentProvider}
|
||||
interactive={managedChatVisible}
|
||||
navigationTarget={
|
||||
sidebarOptions.enabled
|
||||
? managedNavigationTarget
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
/>
|
||||
</WebShellCustomizationProvider>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface WorkspaceLocationOption {
|
|||
interface AddWorkspaceDialogProps {
|
||||
browseDirectories?: boolean;
|
||||
initialPath?: string;
|
||||
pathHint?: string;
|
||||
pathPlaceholder?: string;
|
||||
onClose: () => void;
|
||||
onAdd: (cwd: string, persist: boolean, displayName?: string) => Promise<void>;
|
||||
displayNameEnabled?: boolean;
|
||||
|
|
@ -80,6 +82,8 @@ function isAbsoluteLike(value: string): boolean {
|
|||
export function AddWorkspaceDialog({
|
||||
browseDirectories = false,
|
||||
initialPath = '',
|
||||
pathHint,
|
||||
pathPlaceholder = '/absolute/path or ssh://user@host/project',
|
||||
onClose,
|
||||
onAdd,
|
||||
displayNameEnabled = false,
|
||||
|
|
@ -441,7 +445,7 @@ export function AddWorkspaceDialog({
|
|||
ref={inputRef}
|
||||
id="add-workspace-path"
|
||||
type="text"
|
||||
placeholder="/absolute/path or ssh://user@host/project"
|
||||
placeholder={pathPlaceholder}
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
setPath(e.target.value);
|
||||
|
|
@ -585,7 +589,7 @@ export function AddWorkspaceDialog({
|
|||
)}
|
||||
</div>
|
||||
<FieldDescription id={browseDirectories ? undefined : HINT_ID}>
|
||||
{t('sidebar.addWorkspaceHint')}
|
||||
{pathHint ?? t('sidebar.addWorkspaceHint')}
|
||||
</FieldDescription>
|
||||
{error && <FieldError id={ERROR_ID}>{error}</FieldError>}
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ManagedAgentSessionSummary } from './managed-agent-provider';
|
|||
import { useSharedNow } from '../../hooks/useSharedNow';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
import styles from '../StreamingStatus.module.css';
|
||||
|
||||
export function ManagedSessionProgress({
|
||||
summary,
|
||||
|
|
@ -20,34 +21,24 @@ export function ManagedSessionProgress({
|
|||
if (!submitting && !loading && !active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 rounded-md border bg-muted/40 px-3 py-2 text-sm"
|
||||
data-managed-progress
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner aria-hidden="true" />
|
||||
<span role="status">
|
||||
{submitting
|
||||
? t('managed.sending')
|
||||
: loading
|
||||
? t('managed.loading')
|
||||
: t(`managed.phase.${summary!.phase}`)}
|
||||
</span>
|
||||
{active && !submitting && !loading && (
|
||||
<span className="text-muted-foreground">
|
||||
{t('managed.elapsed', {
|
||||
seconds: Math.max(
|
||||
0,
|
||||
Math.floor((now - summary.admittedAt) / 1000),
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.status} data-managed-progress>
|
||||
<Spinner className={styles.spinner} aria-hidden="true" />
|
||||
<span className={styles.label} role="status">
|
||||
{submitting
|
||||
? t('managed.sending')
|
||||
: loading
|
||||
? t('managed.loading')
|
||||
: t(`managed.phase.${summary!.phase}`)}
|
||||
</span>
|
||||
{active && !submitting && !loading && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('managed.runningHint')}
|
||||
</p>
|
||||
<span className={styles.meta}>
|
||||
{t('managed.elapsed', {
|
||||
seconds: Math.max(0, Math.floor((now - summary.admittedAt) / 1000)),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{active && !submitting && !loading && (
|
||||
<span className="sr-only">{t('managed.runningHint')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -725,7 +725,7 @@ function ManagedSessionsContent({
|
|||
: null
|
||||
: navigation}
|
||||
<section
|
||||
className={`${shellStyles.chatPane} gap-3`}
|
||||
className={`${shellStyles.chatPane} ${home ? '' : 'gap-3'}`}
|
||||
data-managed-home={home || undefined}
|
||||
aria-label={t('managed.conversation')}
|
||||
>
|
||||
|
|
@ -918,6 +918,7 @@ function ManagedSessionsContent({
|
|||
sessionKey={`managed:${provider.storageKey}:${sessionId ?? 'new'}`}
|
||||
loadingTranscript={detail.loading}
|
||||
isResponding={Boolean(active)}
|
||||
activeTurnStartedAt={active ? summary?.admittedAt : undefined}
|
||||
hasOlderHistory={Boolean(detail.olderCursor)}
|
||||
loadingOlderHistory={detail.loadingOlder}
|
||||
onLoadOlderHistory={detail.loadOlder}
|
||||
|
|
|
|||
|
|
@ -244,7 +244,12 @@ describe('WorkspaceBindingCreator', () => {
|
|||
it('registers and selects a remote Workspace without creating a Session', async () => {
|
||||
const registration = enableRegistration();
|
||||
const createSession = directCreation();
|
||||
vi.mocked(provider.workspaceBinding!.get).mockResolvedValue({
|
||||
...remoteWorkspace,
|
||||
defaultCwdRelative: 'remote-project',
|
||||
});
|
||||
await render();
|
||||
await typeInput('managed-workspace-cwd', 'local-project');
|
||||
expect(editorMocks.props!.existingFolderWorkspaceSupported).toBe(true);
|
||||
await openRegistration(true);
|
||||
expect(registration.checkDirectory).toHaveBeenCalledWith(
|
||||
|
|
@ -265,6 +270,10 @@ describe('WorkspaceBindingCreator', () => {
|
|||
expect.objectContaining({ clientId: 'client-a' }),
|
||||
);
|
||||
expect(editorMocks.props!.selectedWorkspaceCwd).toBe('ws-remote');
|
||||
expect(
|
||||
(document.getElementById('managed-workspace-cwd') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('remote-project');
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(createEmpty).not.toHaveBeenCalled();
|
||||
|
|
@ -415,7 +424,10 @@ describe('WorkspaceBindingCreator', () => {
|
|||
expect(sessionStorage.getItem(storageKey)).toBeNull();
|
||||
expect(registration.create).toHaveBeenCalledOnce();
|
||||
expect(registration.query).toHaveBeenCalledOnce();
|
||||
expect(provider.workspaceBinding!.get).not.toHaveBeenCalled();
|
||||
expect(provider.workspaceBinding!.get).not.toHaveBeenCalledWith(
|
||||
'ws-remote',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
(document.getElementById('managed-runtime-directory') as HTMLInputElement)
|
||||
.disabled,
|
||||
|
|
@ -547,6 +559,28 @@ describe('WorkspaceBindingCreator', () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function submitDirectory(path: string) {
|
||||
await click('Choose existing directory');
|
||||
await act(async () => {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'#add-workspace-path',
|
||||
)!;
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!.call(input, path);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector('[role="dialog"] form')!
|
||||
.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
function approvalSelection() {
|
||||
const createSession = directCreation();
|
||||
const getSessionOptions = vi.fn().mockResolvedValue({
|
||||
|
|
@ -992,6 +1026,202 @@ describe('WorkspaceBindingCreator', () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it('selects an existing Linux project and restores its directory after reload', async () => {
|
||||
const created = {
|
||||
...workspace,
|
||||
workspaceId: 'ws-project',
|
||||
displayName: 'Project',
|
||||
defaultCwdRelative: 'project',
|
||||
};
|
||||
const createWorkspace = workspaceCreation().mockResolvedValue(created);
|
||||
provider.workspaceBinding = {
|
||||
...provider.workspaceBinding!,
|
||||
directories: vi.fn().mockResolvedValue({
|
||||
root: '/linux/projects',
|
||||
cwdRelative: '.',
|
||||
directories: [{ name: 'project', cwdRelative: 'project' }],
|
||||
truncated: false,
|
||||
}),
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: [{ ...workspace, canSelectDirectory: true }],
|
||||
defaultWorkspace: { ...workspace, canSelectDirectory: true },
|
||||
supported: true,
|
||||
canCreateWorkspace: true,
|
||||
canSelectDirectory: true,
|
||||
}),
|
||||
get: vi.fn().mockResolvedValue(created),
|
||||
};
|
||||
await render();
|
||||
await openWorkspaceCreation();
|
||||
await click('Choose existing directory');
|
||||
await act(async () => {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'#add-workspace-path',
|
||||
)!;
|
||||
expect(input).not.toBeNull();
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!.call(input, '/linux/projects/project');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector('[role="dialog"] form')!
|
||||
.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
expect(createWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
displayName: 'project',
|
||||
sourceWorkspaceId: 'ws-a',
|
||||
cwdRelative: 'project',
|
||||
},
|
||||
expect.objectContaining({ idempotencyKey: expect.any(String) }),
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('#managed-workspace-cwd')
|
||||
?.value,
|
||||
).toBe('project');
|
||||
expect(editorMocks.clearText).not.toHaveBeenCalled();
|
||||
await act(async () => root.render(null));
|
||||
await render();
|
||||
expect(provider.workspaceBinding.get).toHaveBeenCalledWith(
|
||||
'ws-project',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('#managed-workspace-cwd')
|
||||
?.value,
|
||||
).toBe('project');
|
||||
});
|
||||
|
||||
it('retries a persisted existing-directory creation without changing source or path', async () => {
|
||||
const createWorkspace = workspaceCreation().mockResolvedValue({
|
||||
...workspace,
|
||||
workspaceId: 'ws-project',
|
||||
defaultCwdRelative: 'old/project',
|
||||
});
|
||||
vi.mocked(provider.workspaceBinding!.list).mockResolvedValue({
|
||||
data: [workspace],
|
||||
supported: true,
|
||||
canSelectDirectory: true,
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
`qwen-managed-workspace-registration:${provider.storageKey}`,
|
||||
JSON.stringify({
|
||||
displayName: 'Project',
|
||||
sourceWorkspaceId: 'ws-a',
|
||||
cwdRelative: 'old/project',
|
||||
idempotencyKey: 'frozen-directory-key',
|
||||
}),
|
||||
);
|
||||
await render();
|
||||
await click('Retry the same request');
|
||||
expect(createWorkspace).toHaveBeenCalledWith(
|
||||
{
|
||||
displayName: 'Project',
|
||||
sourceWorkspaceId: 'ws-a',
|
||||
cwdRelative: 'old/project',
|
||||
},
|
||||
expect.objectContaining({ idempotencyKey: 'frozen-directory-key' }),
|
||||
);
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>('#managed-workspace-cwd')
|
||||
?.value,
|
||||
).toBe('old/project');
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'allows correcting a refused directory after abandoning local confirmation (refresh %s)',
|
||||
async (refresh) => {
|
||||
const createWorkspace = workspaceCreation()
|
||||
.mockRejectedValueOnce(
|
||||
new JavaManagedAgentHttpError(
|
||||
409,
|
||||
'workspace_unavailable',
|
||||
'Missing directory',
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
...workspace,
|
||||
workspaceId: 'ws-project',
|
||||
defaultCwdRelative: 'project',
|
||||
});
|
||||
provider.workspaceBinding = {
|
||||
...provider.workspaceBinding!,
|
||||
directories: vi.fn().mockResolvedValue({
|
||||
root: '/linux/projects',
|
||||
cwdRelative: '.',
|
||||
directories: [{ name: 'project', cwdRelative: 'project' }],
|
||||
truncated: false,
|
||||
}),
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: [{ ...workspace, canSelectDirectory: true }],
|
||||
supported: true,
|
||||
canCreateWorkspace: true,
|
||||
canSelectDirectory: true,
|
||||
}),
|
||||
};
|
||||
await render();
|
||||
await openWorkspaceCreation();
|
||||
await submitDirectory('/linux/projects/missing');
|
||||
const key = `qwen-managed-workspace-registration:${provider.storageKey}`;
|
||||
const original = JSON.parse(sessionStorage.getItem(key)!);
|
||||
if (refresh) {
|
||||
await act(async () => root.render(null));
|
||||
await render();
|
||||
}
|
||||
expect(
|
||||
document.querySelector<HTMLInputElement>('#managed-new-workspace-name')
|
||||
?.disabled,
|
||||
).toBe(true);
|
||||
await click('Abandon local confirmation');
|
||||
expect(sessionStorage.getItem(key)).toBeNull();
|
||||
expect(
|
||||
document.querySelector<HTMLInputElement>('#managed-new-workspace-name')
|
||||
?.disabled,
|
||||
).toBe(false);
|
||||
await submitDirectory('/linux/projects/project');
|
||||
expect(createWorkspace).toHaveBeenCalledTimes(2);
|
||||
expect(createWorkspace.mock.calls[1][0]).toMatchObject({
|
||||
sourceWorkspaceId: 'ws-a',
|
||||
cwdRelative: 'project',
|
||||
});
|
||||
expect(createWorkspace.mock.calls[1][1].idempotencyKey).not.toBe(
|
||||
original.idempotencyKey,
|
||||
);
|
||||
expect(editorMocks.clearText).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('allows empty Workspace creation after abandoning a persisted directory selection', async () => {
|
||||
const createWorkspace = workspaceCreation();
|
||||
sessionStorage.setItem(
|
||||
`qwen-managed-workspace-registration:${provider.storageKey}`,
|
||||
JSON.stringify({
|
||||
displayName: 'Missing',
|
||||
sourceWorkspaceId: 'ws-a',
|
||||
cwdRelative: 'missing',
|
||||
idempotencyKey: 'refused-directory',
|
||||
}),
|
||||
);
|
||||
await render();
|
||||
await click('Abandon local confirmation');
|
||||
expect(createWorkspace).not.toHaveBeenCalled();
|
||||
await nameWorkspace('New empty');
|
||||
await click('Create workspace');
|
||||
expect(createWorkspace).toHaveBeenCalledWith(
|
||||
{ displayName: 'New empty' },
|
||||
expect.objectContaining({ idempotencyKey: expect.any(String) }),
|
||||
);
|
||||
expect(createWorkspace.mock.calls[0][1].idempotencyKey).not.toBe(
|
||||
'refused-directory',
|
||||
);
|
||||
});
|
||||
|
||||
it('freezes an uncertain creation across close and retry without issuing another operation', async () => {
|
||||
const createWorkspace = workspaceCreation()
|
||||
.mockRejectedValueOnce(new Error('response lost'))
|
||||
|
|
@ -1468,7 +1698,11 @@ describe('WorkspaceBindingCreator', () => {
|
|||
expect(createEmpty).toHaveBeenCalledTimes(1);
|
||||
expect(getSession).toHaveBeenCalledTimes(1);
|
||||
expect(onCreated).toHaveBeenCalledExactlyOnceWith('session-a');
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
expect(
|
||||
sessionStorage.getItem(
|
||||
`qwen-managed-workspace-create:${provider.storageKey}`,
|
||||
),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1499,7 +1733,11 @@ describe('WorkspaceBindingCreator', () => {
|
|||
createEmpty.mock.calls[0][1].idempotencyKey,
|
||||
);
|
||||
expect(onCreated).toHaveBeenCalledWith('session-a');
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
expect(
|
||||
sessionStorage.getItem(
|
||||
`qwen-managed-workspace-create:${provider.storageKey}`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -1571,7 +1809,11 @@ describe('WorkspaceBindingCreator', () => {
|
|||
});
|
||||
await render();
|
||||
await click('Create session');
|
||||
expect(sessionStorage.length).toBe(1);
|
||||
expect(
|
||||
sessionStorage.getItem(
|
||||
`qwen-managed-workspace-create:${provider.storageKey}`,
|
||||
),
|
||||
).not.toBeNull();
|
||||
await click('Retry the same request');
|
||||
expect(createEmpty).toHaveBeenCalledTimes(2);
|
||||
expect(createEmpty.mock.calls[1]).toEqual(createEmpty.mock.calls[0]);
|
||||
|
|
@ -1694,7 +1936,11 @@ describe('WorkspaceBindingCreator', () => {
|
|||
);
|
||||
await render();
|
||||
await click('Create session');
|
||||
expect(sessionStorage.length).toBe(0);
|
||||
expect(
|
||||
sessionStorage.getItem(
|
||||
`qwen-managed-workspace-create:${provider.storageKey}`,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(container.querySelector('#managed-workspace-cwd')).not.toBeNull();
|
||||
expect(container.textContent).toContain('invalid directory');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import shellStyles from '../../App.module.css';
|
|||
import { useI18n } from '../../i18n';
|
||||
import { ChatEditor } from '../ChatEditor';
|
||||
import { WelcomeHeader } from '../WelcomeHeader';
|
||||
import { AddWorkspaceDialog } from '../dialogs/AddWorkspaceDialog';
|
||||
import type {
|
||||
ComposerSubmitCommit,
|
||||
EditorHandle,
|
||||
|
|
@ -59,6 +60,8 @@ interface PendingCreation {
|
|||
interface PendingWorkspace {
|
||||
displayName: string;
|
||||
idempotencyKey: string;
|
||||
sourceWorkspaceId?: string;
|
||||
cwdRelative?: string;
|
||||
}
|
||||
|
||||
function validWorkspaceName(value: string): boolean {
|
||||
|
|
@ -77,6 +80,10 @@ function readPendingWorkspace(key: string): PendingWorkspace | undefined {
|
|||
typeof item['idempotencyKey'] === 'string' &&
|
||||
item['idempotencyKey'].length > 0 &&
|
||||
item['idempotencyKey'].length <= 128 &&
|
||||
((item['sourceWorkspaceId'] === undefined &&
|
||||
item['cwdRelative'] === undefined) ||
|
||||
(typeof item['sourceWorkspaceId'] === 'string' &&
|
||||
typeof item['cwdRelative'] === 'string')) &&
|
||||
!('nodeId' in item) &&
|
||||
!('path' in item) &&
|
||||
!('operationId' in item)
|
||||
|
|
@ -84,6 +91,8 @@ function readPendingWorkspace(key: string): PendingWorkspace | undefined {
|
|||
return {
|
||||
displayName: item['displayName'],
|
||||
idempotencyKey: item['idempotencyKey'],
|
||||
sourceWorkspaceId: item['sourceWorkspaceId'] as string | undefined,
|
||||
cwdRelative: item['cwdRelative'] as string | undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -170,6 +179,12 @@ export function WorkspaceBindingCreator({
|
|||
const workspaceCreatingRef = useRef(false);
|
||||
const [workspaceError, setWorkspaceError] = useState<string>();
|
||||
const [canCreateWorkspace, setCanCreateWorkspace] = useState(false);
|
||||
const [canSelectDirectory, setCanSelectDirectory] = useState(false);
|
||||
const [directorySource, setDirectorySource] = useState('');
|
||||
const [directoryPicker, setDirectoryPicker] = useState<{
|
||||
workspaceId: string;
|
||||
root: string;
|
||||
}>();
|
||||
const [supportsApprovalModeSelection, setSupportsApprovalModeSelection] =
|
||||
useState(false);
|
||||
const [sessionOptions, setSessionOptions] = useState<
|
||||
|
|
@ -179,10 +194,18 @@ export function WorkspaceBindingCreator({
|
|||
const [optionsError, setOptionsError] = useState<string>();
|
||||
const [optionsRevision, setOptionsRevision] = useState(0);
|
||||
const [items, setItems] = useState<ManagedAgentWorkspace[]>([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
const selectionKey = `qwen-managed-workspace-selection:${provider.storageKey}`;
|
||||
const [selected, setSelected] = useState(() => {
|
||||
try {
|
||||
return sessionStorage.getItem(selectionKey) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const [cwdRelative, setCwdRelative] = useState('.');
|
||||
const cwdEdited = useRef(false);
|
||||
const [agentId, setAgentId] = useState(binding.agentId);
|
||||
const modelPresets = binding.modelPresets ?? [];
|
||||
const availableModels = [
|
||||
|
|
@ -213,6 +236,14 @@ export function WorkspaceBindingCreator({
|
|||
return () => abort.abort();
|
||||
}, [pendingKey]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem(selectionKey, selected);
|
||||
} catch {
|
||||
/* Selection is advisory. */
|
||||
}
|
||||
}, [selectionKey, selected]);
|
||||
|
||||
function updatePending(value?: PendingCreation) {
|
||||
pendingRef.current = value;
|
||||
setPending(value);
|
||||
|
|
@ -235,6 +266,7 @@ export function WorkspaceBindingCreator({
|
|||
if (abort.signal.aborted) return;
|
||||
setSupported(page.supported);
|
||||
setCanCreateWorkspace(page.canCreateWorkspace === true);
|
||||
setCanSelectDirectory(page.canSelectDirectory === true);
|
||||
setSupportsApprovalModeSelection(
|
||||
page.supportsApprovalModeSelection === true,
|
||||
);
|
||||
|
|
@ -255,6 +287,8 @@ export function WorkspaceBindingCreator({
|
|||
signal: abort.signal,
|
||||
});
|
||||
if (abort.signal.aborted) return;
|
||||
if (!cwdEdited.current)
|
||||
setCwdRelative(retained.defaultCwdRelative ?? '.');
|
||||
setItems((current) => [
|
||||
...new Map(
|
||||
[...current, retained].map((item) => [item.workspaceId, item]),
|
||||
|
|
@ -266,7 +300,7 @@ export function WorkspaceBindingCreator({
|
|||
? (page.defaultWorkspace?.workspaceId ?? '')
|
||||
: '',
|
||||
);
|
||||
setCwdRelative('.');
|
||||
setCwdRelative(page.defaultWorkspace?.defaultCwdRelative ?? '.');
|
||||
}
|
||||
} catch (failure) {
|
||||
if (abort.signal.aborted) return;
|
||||
|
|
@ -282,7 +316,7 @@ export function WorkspaceBindingCreator({
|
|||
? (page.defaultWorkspace?.workspaceId ?? '')
|
||||
: '',
|
||||
);
|
||||
setCwdRelative('.');
|
||||
setCwdRelative(page.defaultWorkspace?.defaultCwdRelative ?? '.');
|
||||
} else {
|
||||
setError(
|
||||
`${t('managed.workspaceLoadFailed')} ${message(failure)}`,
|
||||
|
|
@ -291,11 +325,13 @@ export function WorkspaceBindingCreator({
|
|||
}
|
||||
} else {
|
||||
setSelected(page.defaultWorkspace?.workspaceId ?? '');
|
||||
setCwdRelative(page.defaultWorkspace?.defaultCwdRelative ?? '.');
|
||||
}
|
||||
} catch (failure) {
|
||||
if (abort.signal.aborted) return;
|
||||
setSupported(false);
|
||||
setCanCreateWorkspace(false);
|
||||
setCanSelectDirectory(false);
|
||||
setSupportsApprovalModeSelection(false);
|
||||
setError(
|
||||
failure instanceof JavaManagedAgentHttpError && failure.status === 404
|
||||
|
|
@ -548,18 +584,37 @@ export function WorkspaceBindingCreator({
|
|||
if (!workspaceId) return;
|
||||
if (workspaceId === selected) return;
|
||||
setError(undefined);
|
||||
if (cwdRelative !== '.') setSwitchTo(workspaceId);
|
||||
else setSelected(workspaceId);
|
||||
if (
|
||||
cwdRelative !==
|
||||
(items.find((item) => item.workspaceId === selected)
|
||||
?.defaultCwdRelative ?? '.')
|
||||
)
|
||||
setSwitchTo(workspaceId);
|
||||
else selectWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
async function createWorkspace() {
|
||||
function selectWorkspace(workspaceId: string) {
|
||||
setSelected(workspaceId);
|
||||
setCwdRelative(
|
||||
items.find((item) => item.workspaceId === workspaceId)
|
||||
?.defaultCwdRelative ?? '.',
|
||||
);
|
||||
cwdEdited.current = false;
|
||||
}
|
||||
|
||||
async function createWorkspace(
|
||||
directory?: Omit<PendingWorkspace, 'idempotencyKey'>,
|
||||
) {
|
||||
const abort = commandLifetime.current;
|
||||
if (
|
||||
!interactive ||
|
||||
!abort ||
|
||||
abort.signal.aborted ||
|
||||
!binding.createWorkspace ||
|
||||
!canCreateWorkspace ||
|
||||
!(directory?.sourceWorkspaceId ||
|
||||
pendingWorkspaceRef.current?.sourceWorkspaceId
|
||||
? canSelectDirectory
|
||||
: canCreateWorkspace) ||
|
||||
busy ||
|
||||
pendingRef.current ||
|
||||
workspaceCreatingRef.current
|
||||
|
|
@ -568,16 +623,23 @@ export function WorkspaceBindingCreator({
|
|||
let attempt = pendingWorkspaceRef.current;
|
||||
const firstSubmission = !attempt;
|
||||
if (!attempt) {
|
||||
const displayName = workspaceName.trim();
|
||||
const displayName = (directory?.displayName ?? workspaceName).trim();
|
||||
if (!validWorkspaceName(displayName)) {
|
||||
setWorkspaceError(t('managed.newWorkspaceNameInvalid'));
|
||||
if (directory) throw new Error(t('managed.newWorkspaceNameInvalid'));
|
||||
return;
|
||||
}
|
||||
attempt = { displayName, idempotencyKey: managedRequestId() };
|
||||
attempt = {
|
||||
...directory,
|
||||
displayName,
|
||||
idempotencyKey: managedRequestId(),
|
||||
};
|
||||
try {
|
||||
sessionStorage.setItem(workspacePendingKey, JSON.stringify(attempt));
|
||||
} catch {
|
||||
setWorkspaceError(t('managed.workspaceStorageUnavailable'));
|
||||
if (directory)
|
||||
throw new Error(t('managed.workspaceStorageUnavailable'));
|
||||
return;
|
||||
}
|
||||
pendingWorkspaceRef.current = attempt;
|
||||
|
|
@ -589,7 +651,15 @@ export function WorkspaceBindingCreator({
|
|||
setWorkspaceError(undefined);
|
||||
try {
|
||||
const created = await binding.createWorkspace(
|
||||
{ displayName: attempt.displayName },
|
||||
{
|
||||
displayName: attempt.displayName,
|
||||
...(attempt.sourceWorkspaceId
|
||||
? {
|
||||
sourceWorkspaceId: attempt.sourceWorkspaceId,
|
||||
cwdRelative: attempt.cwdRelative,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
clientId,
|
||||
idempotencyKey: attempt.idempotencyKey,
|
||||
|
|
@ -603,7 +673,8 @@ export function WorkspaceBindingCreator({
|
|||
).values(),
|
||||
]);
|
||||
setSelected(created.workspaceId);
|
||||
setCwdRelative('.');
|
||||
setCwdRelative(created.defaultCwdRelative ?? '.');
|
||||
cwdEdited.current = false;
|
||||
pendingWorkspaceRef.current = undefined;
|
||||
setPendingWorkspace(undefined);
|
||||
try {
|
||||
|
|
@ -620,6 +691,8 @@ export function WorkspaceBindingCreator({
|
|||
failure instanceof JavaManagedAgentHttpError &&
|
||||
[
|
||||
'invalid_workspace_name',
|
||||
'invalid_cwd',
|
||||
'invalid_request',
|
||||
'invalid_idempotency_key',
|
||||
'workspace_creation_unavailable',
|
||||
].includes(failure.code)
|
||||
|
|
@ -633,6 +706,7 @@ export function WorkspaceBindingCreator({
|
|||
}
|
||||
}
|
||||
setWorkspaceError(message(failure));
|
||||
if (directory) setWorkspaceDialogOpen(true);
|
||||
} finally {
|
||||
workspaceCreatingRef.current = false;
|
||||
if (!abort.signal.aborted) setWorkspaceCreating(false);
|
||||
|
|
@ -641,9 +715,28 @@ export function WorkspaceBindingCreator({
|
|||
|
||||
const directInput = Boolean(binding.createSession);
|
||||
const workspaceCreationSupported = Boolean(
|
||||
canCreateWorkspace && binding.createWorkspace,
|
||||
(canCreateWorkspace || canSelectDirectory) && binding.createWorkspace,
|
||||
);
|
||||
|
||||
async function browseDirectories() {
|
||||
const workspaceId =
|
||||
directorySource ||
|
||||
items.find((item) => item.canSelectDirectory)?.workspaceId;
|
||||
if (!workspaceId || !binding.directories) return;
|
||||
setWorkspaceError(undefined);
|
||||
try {
|
||||
const result = await binding.directories(
|
||||
{ workspaceId, cwdRelative: '.' },
|
||||
{ clientId, signal: commandLifetime.current?.signal },
|
||||
);
|
||||
if (commandLifetime.current?.signal.aborted) return;
|
||||
setDirectoryPicker({ workspaceId, root: result.root });
|
||||
setWorkspaceDialogOpen(false);
|
||||
} catch (failure) {
|
||||
setWorkspaceError(message(failure));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${directInput ? shellStyles.appChatEmpty : ''} flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-2`}
|
||||
|
|
@ -885,6 +978,7 @@ export function WorkspaceBindingCreator({
|
|||
id="managed-workspace-cwd"
|
||||
value={cwdRelative}
|
||||
onChange={(event) => {
|
||||
cwdEdited.current = true;
|
||||
setCwdRelative(event.target.value);
|
||||
setPathError(undefined);
|
||||
}}
|
||||
|
|
@ -1038,7 +1132,10 @@ export function WorkspaceBindingCreator({
|
|||
!approvalReady
|
||||
}
|
||||
dialogOpen={
|
||||
!interactive || workspaceDialogOpen || registrationOpen
|
||||
!interactive ||
|
||||
workspaceDialogOpen ||
|
||||
Boolean(directoryPicker) ||
|
||||
registrationOpen
|
||||
}
|
||||
isPreparing={busy || loading || optionsLoading}
|
||||
placeholderText={t('managed.prompt')}
|
||||
|
|
@ -1061,6 +1158,7 @@ export function WorkspaceBindingCreator({
|
|||
disabled={busy || Boolean(pending)}
|
||||
aria-invalid={Boolean(pathError)}
|
||||
onChange={(event) => {
|
||||
cwdEdited.current = true;
|
||||
setCwdRelative(event.target.value);
|
||||
setPathError(undefined);
|
||||
}}
|
||||
|
|
@ -1088,6 +1186,54 @@ export function WorkspaceBindingCreator({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{interactive && directoryPicker && binding.directories && (
|
||||
<AddWorkspaceDialog
|
||||
browseDirectories
|
||||
initialPath={`${directoryPicker.root}/`}
|
||||
pathHint={t('managed.workspaceDirectoryHint')}
|
||||
pathPlaceholder="/absolute/project/path"
|
||||
displayNameEnabled
|
||||
persistenceSupported={false}
|
||||
onClose={() => setDirectoryPicker(undefined)}
|
||||
onSuggest={async (prefix) => {
|
||||
const root = directoryPicker.root;
|
||||
const clean = prefix.replace(/\/+$/, '');
|
||||
if (clean !== root && !clean.startsWith(`${root}/`))
|
||||
throw new Error(t('managed.workspaceDirectoryScope'));
|
||||
const result = await binding.directories!(
|
||||
{
|
||||
workspaceId: directoryPicker.workspaceId,
|
||||
cwdRelative: clean.slice(root.length + 1) || '.',
|
||||
},
|
||||
{ clientId, signal: commandLifetime.current?.signal },
|
||||
);
|
||||
return {
|
||||
dir: clean,
|
||||
sep: '/',
|
||||
truncated: result.truncated,
|
||||
suggestions: result.directories.map((item) => ({
|
||||
name: item.name,
|
||||
path: `${root}/${item.cwdRelative}`,
|
||||
})),
|
||||
};
|
||||
}}
|
||||
onAdd={async (path, _persist, displayName) => {
|
||||
const root = directoryPicker.root;
|
||||
const clean = path.replace(/\/+$/, '');
|
||||
if (clean !== root && !clean.startsWith(`${root}/`))
|
||||
throw new Error(t('managed.workspaceDirectoryScope'));
|
||||
await createWorkspace({
|
||||
displayName:
|
||||
displayName ||
|
||||
workspaceName ||
|
||||
clean.split('/').pop() ||
|
||||
'Workspace',
|
||||
sourceWorkspaceId: directoryPicker.workspaceId,
|
||||
cwdRelative: clean.slice(root.length + 1) || '.',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{interactive && binding.registration && (
|
||||
<RemoteWorkspaceRegistration
|
||||
key={provider.storageKey}
|
||||
|
|
@ -1104,7 +1250,8 @@ export function WorkspaceBindingCreator({
|
|||
workspace,
|
||||
]);
|
||||
setSelected(workspace.workspaceId);
|
||||
setCwdRelative('.');
|
||||
setCwdRelative(workspace.defaultCwdRelative ?? '.');
|
||||
cwdEdited.current = false;
|
||||
setError(undefined);
|
||||
setPathError(undefined);
|
||||
}}
|
||||
|
|
@ -1114,7 +1261,11 @@ export function WorkspaceBindingCreator({
|
|||
open={interactive && workspaceDialogOpen}
|
||||
onOpenChange={setWorkspaceDialogOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (directoryPicker) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
|
|
@ -1128,6 +1279,55 @@ export function WorkspaceBindingCreator({
|
|||
{t('managed.newWorkspaceDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{canSelectDirectory && binding.directories && !pendingWorkspace && (
|
||||
<div className="space-y-2">
|
||||
<label id="managed-directory-root-label">
|
||||
{t('managed.workspaceDirectoryRoot')}
|
||||
</label>
|
||||
<Select
|
||||
value={
|
||||
directorySource ||
|
||||
items.find((item) => item.canSelectDirectory)
|
||||
?.workspaceId ||
|
||||
''
|
||||
}
|
||||
onValueChange={setDirectorySource}
|
||||
>
|
||||
<SelectTrigger aria-labelledby="managed-directory-root-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items
|
||||
.filter((item) => item.canSelectDirectory)
|
||||
.map((item) => (
|
||||
<SelectItem
|
||||
key={item.workspaceId}
|
||||
value={item.workspaceId}
|
||||
>
|
||||
{item.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
workspaceCreating ||
|
||||
!items.some((item) => item.canSelectDirectory)
|
||||
}
|
||||
onClick={() => void browseDirectories()}
|
||||
>
|
||||
{t('managed.workspaceChooseDirectory')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{pendingWorkspace?.sourceWorkspaceId && (
|
||||
<p className="text-sm">
|
||||
{t('managed.workspaceDirectory')}:{' '}
|
||||
{pendingWorkspace.cwdRelative}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="managed-new-workspace-name">
|
||||
{t('managed.newWorkspaceName')}
|
||||
|
|
@ -1165,6 +1365,26 @@ export function WorkspaceBindingCreator({
|
|||
>
|
||||
{t('managed.newWorkspaceClose')}
|
||||
</Button>
|
||||
{pendingWorkspace && !workspaceCreating && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
try {
|
||||
sessionStorage.removeItem(workspacePendingKey);
|
||||
pendingWorkspaceRef.current = undefined;
|
||||
setPendingWorkspace(undefined);
|
||||
setWorkspaceError(undefined);
|
||||
} catch {
|
||||
setWorkspaceError(
|
||||
t('managed.workspaceStorageUnavailable'),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('managed.workspaceAbandon')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
|
|
@ -1174,7 +1394,8 @@ export function WorkspaceBindingCreator({
|
|||
busy ||
|
||||
Boolean(pending) ||
|
||||
(!pendingWorkspace &&
|
||||
!validWorkspaceName(workspaceName.trim()))
|
||||
(!canCreateWorkspace ||
|
||||
!validWorkspaceName(workspaceName.trim())))
|
||||
}
|
||||
>
|
||||
{pendingWorkspace
|
||||
|
|
@ -1204,8 +1425,7 @@ export function WorkspaceBindingCreator({
|
|||
<AlertDialogCancel>{t('managed.workspaceKeep')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (switchTo) setSelected(switchTo);
|
||||
setCwdRelative('.');
|
||||
if (switchTo) selectWorkspace(switchTo);
|
||||
setSwitchTo(undefined);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -423,7 +423,7 @@ export interface paths {
|
|||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description Create an independent empty Workspace under the operator-controlled creation root and grant its authenticated creator OWNER access. Disabled deployments reject creation. The actor-scoped idempotency key freezes the normalized name and storage registration; retry the same request after an uncertain response. Completed replay checks current access and does not restore removed Workspaces or revoked grants. No host path is accepted or returned. */
|
||||
/** @description Create an empty Workspace under the operator-controlled root, or supply sourceWorkspaceId and cwdRelative together to select an existing directory under an ACTIVE ROOT Workspace owned by the actor. Existing-directory creation shares its verified storage, config and policy and persists a default working directory. The actor-scoped idempotency key pins the normalized request and source generation. Completed replay checks current access and never restores grants. */
|
||||
post: operations["webShellCreateWorkspace"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
|
|
@ -584,6 +584,23 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/agent/web-shell/v1/workspaces/directories/query": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @description List bounded immediate real directories on the selected Workspace storage host. Requires an authenticated OWNER of the ACTIVE ROOT Workspace and verified LOCAL storage. Refuses traversal, symlinks and reserved maintenance directories; never uses the ordinary daemon filesystem. Root paths are disclosed only within this authorized scope. */
|
||||
post: operations["queryWebShellWorkspaceDirectories"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
|
|
@ -897,6 +914,10 @@ export interface components {
|
|||
canCreateSession: boolean;
|
||||
/** @enum {string} */
|
||||
state: "active" | "draining" | "removed";
|
||||
/** @description Persisted default working directory for new WebShell Sessions; absent means dot. */
|
||||
defaultCwdRelative?: string;
|
||||
/** @description Current actor owns this active root Workspace; directory discovery rechecks authorization and native storage. */
|
||||
canSelectDirectory?: boolean;
|
||||
};
|
||||
WebShellWorkspacePage: {
|
||||
data: components["schemas"]["WebShellWorkspace"][];
|
||||
|
|
@ -914,6 +935,8 @@ export interface components {
|
|||
workspaceCreation?: boolean;
|
||||
/** @description Supports effective Session policy options and explicit Workspace-bound creation policy. Missing or false means unsupported. */
|
||||
approvalModeSelection?: boolean;
|
||||
/** @description Supports browsing and selecting existing directories on authorized Linux storage; absent means unsupported. */
|
||||
workspaceDirectorySelection?: boolean;
|
||||
};
|
||||
/** @description Same authorized explicit default as default_workspace, including when outside this page; null if absent or not creatable. A non-null default is active and has canCreateSession=true. */
|
||||
defaultWorkspace: (components["schemas"]["WebShellWorkspace"] & {
|
||||
|
|
@ -1305,6 +1328,8 @@ export interface components {
|
|||
/** @description Trimmed display name, without control characters. The server validates the normalized name. */
|
||||
displayName: string;
|
||||
idempotencyKey: string;
|
||||
sourceWorkspaceId?: string;
|
||||
cwdRelative?: string;
|
||||
};
|
||||
WebShellSessionOptionsRequest: {
|
||||
agentId: string;
|
||||
|
|
@ -1429,6 +1454,19 @@ export interface components {
|
|||
/** @description Trace correlation only; excluded from the request digest, so a retry with another requestId still replays. A header-safe value (1 to 128 visible ASCII characters) is echoed as X-Request-Id; any other value is replaced by a server-chosen id. */
|
||||
requestId?: string | null;
|
||||
};
|
||||
WebShellWorkspaceDirectoryPage: {
|
||||
root: string;
|
||||
cwdRelative: string;
|
||||
truncated: boolean;
|
||||
directories: {
|
||||
name: string;
|
||||
cwdRelative: string;
|
||||
}[];
|
||||
};
|
||||
WebShellWorkspaceDirectoryQueryRequest: {
|
||||
workspaceId: string;
|
||||
cwdRelative: string;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description The authenticated actor belongs to another tenant than X-Qwen-Tenant-Id or has an invalid ID (actor_scope_mismatch, which the tenant filter answers on every /v1/agents/ and WebShell route), or the resource is readable but the actor lacks this operation or the original Action responder permission. Artifact content reads may also return artifact_content_forbidden when the actor cannot read the original bytes. */
|
||||
|
|
@ -2677,4 +2715,33 @@ export interface operations {
|
|||
413: components["responses"]["PayloadTooLarge"];
|
||||
};
|
||||
};
|
||||
queryWebShellWorkspaceDirectories: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebShellWorkspaceDirectoryQueryRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Authorized existing directories. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WebShellWorkspaceDirectoryPage"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,13 @@ export class JavaManagedAgentClient {
|
|||
return this.post('/workspaces/create', request, signal);
|
||||
}
|
||||
|
||||
workspaceDirectories(
|
||||
request: Schemas['WebShellWorkspaceDirectoryQueryRequest'],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Schemas['WebShellWorkspaceDirectoryPage']> {
|
||||
return this.post('/workspaces/directories/query', request, signal);
|
||||
}
|
||||
|
||||
getSessionApproval(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
|
|
|
|||
|
|
@ -463,6 +463,8 @@ export function createJavaManagedAgentProvider(
|
|||
supported: page.capabilities?.workspaceBinding === true,
|
||||
canCreateWorkspace:
|
||||
page.capabilities?.workspaceCreation === true,
|
||||
canSelectDirectory:
|
||||
page.capabilities?.workspaceDirectorySelection === true,
|
||||
supportsApprovalModeSelection:
|
||||
page.capabilities?.approvalModeSelection === true,
|
||||
};
|
||||
|
|
@ -475,6 +477,8 @@ export function createJavaManagedAgentProvider(
|
|||
{ ...request, idempotencyKey: command.idempotencyKey },
|
||||
command.signal,
|
||||
),
|
||||
directories: (request, command) =>
|
||||
client.workspaceDirectories(request, command.signal),
|
||||
getSessionOptions: (agentId, request) =>
|
||||
client.getSessionOptions(agentId, request.signal),
|
||||
createEmpty: (
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ export interface ManagedAgentProvider {
|
|||
nextCursor?: string;
|
||||
supported: boolean;
|
||||
canCreateWorkspace?: boolean;
|
||||
canSelectDirectory?: boolean;
|
||||
supportsApprovalModeSelection?: boolean;
|
||||
}>;
|
||||
get(
|
||||
|
|
@ -316,9 +317,17 @@ export interface ManagedAgentProvider {
|
|||
options: ManagedAgentRequestOptions,
|
||||
): Promise<ManagedAgentWorkspace>;
|
||||
createWorkspace?(
|
||||
request: { displayName: string },
|
||||
request: {
|
||||
displayName: string;
|
||||
sourceWorkspaceId?: string;
|
||||
cwdRelative?: string;
|
||||
},
|
||||
options: ManagedAgentCommandOptions,
|
||||
): Promise<ManagedAgentWorkspace>;
|
||||
directories?(
|
||||
request: { workspaceId: string; cwdRelative: string },
|
||||
options: ManagedAgentRequestOptions,
|
||||
): Promise<components['schemas']['WebShellWorkspaceDirectoryPage']>;
|
||||
getSessionOptions?(
|
||||
agentId: string,
|
||||
options: ManagedAgentRequestOptions,
|
||||
|
|
@ -404,4 +413,6 @@ export interface ManagedAgentWorkspace {
|
|||
displayName: string;
|
||||
state: string;
|
||||
canCreateSession: boolean;
|
||||
defaultCwdRelative?: string;
|
||||
canSelectDirectory?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,56 @@ function event(
|
|||
}
|
||||
|
||||
describe('Managed transcript projection', () => {
|
||||
it('retains completed Turn duration when replayed text parts share their Item creation time', () => {
|
||||
const messages = managedEventsToMessages(
|
||||
[
|
||||
event(1, 'accepted', { prompt: [{ text: 'check' }] }),
|
||||
event(2, 'assistant_delta', { text: 'Starting' }),
|
||||
event(3, 'tool_started', {
|
||||
toolCallId: 'call',
|
||||
toolName: 'run_shell_command',
|
||||
}),
|
||||
event(4, 'tool_completed', { toolCallId: 'call' }),
|
||||
{ ...event(5, 'assistant_delta', { text: 'Finished' }), at: 200 },
|
||||
event(6, 'completed', {}),
|
||||
],
|
||||
'[truncated]',
|
||||
);
|
||||
expect(messages[0].timestamp).toBe(100);
|
||||
expect(messages.at(-1)).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Finished',
|
||||
timestamp: 600,
|
||||
isStreaming: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the original tool clock through repeated progress and separates commentary', () => {
|
||||
const messages = managedEventsToMessages(
|
||||
[
|
||||
event(1, 'assistant_delta', { text: 'Starting' }),
|
||||
event(2, 'tool_started', {
|
||||
toolCallId: 'call',
|
||||
toolName: 'run_shell_command',
|
||||
}),
|
||||
event(3, 'tool_started', {
|
||||
toolCallId: 'call',
|
||||
toolName: 'run_shell_command',
|
||||
}),
|
||||
event(4, 'tool_completed', { toolCallId: 'call' }),
|
||||
event(5, 'assistant_delta', { text: 'Finished' }),
|
||||
],
|
||||
'[truncated]',
|
||||
);
|
||||
expect(messages.map((message) => message.role)).toEqual([
|
||||
'assistant',
|
||||
'tool_group',
|
||||
'assistant',
|
||||
]);
|
||||
expect(messages[1]).toMatchObject({
|
||||
tools: [{ startTime: 200, endTime: 400, status: 'completed' }],
|
||||
});
|
||||
});
|
||||
it('preserves existing output when a result has no preview or a shorter excerpt', () => {
|
||||
const source = { ...result, session_id: 's1', turn_id: 'p1' };
|
||||
const output = 'complete output';
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ export function managedEventsToMessages(
|
|||
}
|
||||
if (event.type === 'tool_started') {
|
||||
tool.status = 'in_progress';
|
||||
tool.startTime = event.at;
|
||||
tool.startTime ??= event.at;
|
||||
// A resumed tool outgrows the diagnostic's floor: the end is open
|
||||
// again, while the booking survives so the Turn's terminal event
|
||||
// can still re-stamp it.
|
||||
|
|
@ -284,6 +284,11 @@ export function managedEventsToMessages(
|
|||
// the live one.
|
||||
(event.type === 'runtime_failed' && event.turnId === currentTurnId)
|
||||
) {
|
||||
if (event.type === 'completed' && textMessage?.role === 'assistant') {
|
||||
// Snapshot text parts share the Item's creation time; the shared
|
||||
// transcript measures a settled Turn through its final assistant.
|
||||
textMessage.timestamp = event.at;
|
||||
}
|
||||
// environment.failed is a non-fatal diagnostic and the Turn keeps
|
||||
// streaming: dropping the continuation handle would split one answer
|
||||
// into two bubbles, so only stop the spinner (and fail pending tools).
|
||||
|
|
|
|||
|
|
@ -228,6 +228,13 @@ describe('tool group summary logic', () => {
|
|||
{ onToolResultOpen: vi.fn() },
|
||||
{},
|
||||
);
|
||||
if (result === previewOnlyResult) {
|
||||
expect(container.textContent).not.toContain(expected);
|
||||
const header = container.querySelector(
|
||||
'[role="button"]',
|
||||
) as HTMLElement;
|
||||
act(() => header.click());
|
||||
}
|
||||
expect(container.textContent).toContain(expected);
|
||||
},
|
||||
);
|
||||
|
|
@ -243,6 +250,9 @@ describe('tool group summary logic', () => {
|
|||
{ onToolResultOpen: onOpen },
|
||||
customization,
|
||||
);
|
||||
expect(container.textContent).not.toContain('Capture complete');
|
||||
const header = container.querySelector('[role="button"]') as HTMLElement;
|
||||
act(() => header.click());
|
||||
expect(container.textContent).toContain('Capture complete');
|
||||
const { root } = mounted.at(-1)!;
|
||||
act(() =>
|
||||
|
|
|
|||
|
|
@ -1618,12 +1618,17 @@ export const ToolLine = memo(function ToolLine({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{tool.toolResult && (
|
||||
<ManagedToolResultSummary
|
||||
result={tool.toolResult}
|
||||
onOpen={onToolResultOpen}
|
||||
/>
|
||||
)}
|
||||
{tool.toolResult &&
|
||||
(expanded ||
|
||||
tool.toolResult.execution_status !== 'success' ||
|
||||
tool.toolResult.capture_status === 'partial' ||
|
||||
tool.toolResult.capture_status === 'unavailable' ||
|
||||
tool.toolResult.delivery_status !== 'committed') && (
|
||||
<ManagedToolResultSummary
|
||||
result={tool.toolResult}
|
||||
onOpen={onToolResultOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, areToolLinePropsEqual);
|
||||
|
|
|
|||
|
|
@ -298,14 +298,20 @@ const EN: Messages = {
|
|||
'managed.approvalFixed':
|
||||
'Approval mode is fixed for this task. Start a new task to choose another mode.',
|
||||
'managed.newWorkspaceDescription':
|
||||
'Create an empty workspace for Managed tasks.',
|
||||
'Choose an existing project directory, or name a new empty workspace.',
|
||||
'managed.workspaceDirectoryRoot': 'Authorized Linux directories',
|
||||
'managed.workspaceDirectoryHint':
|
||||
'Choose an existing project on the Linux execution host within this authorized root.',
|
||||
'managed.workspaceChooseDirectory': 'Choose existing directory',
|
||||
'managed.workspaceDirectoryScope':
|
||||
'Choose a directory inside the selected root.',
|
||||
'managed.newWorkspaceName': 'Workspace name',
|
||||
'managed.newWorkspaceNameInvalid':
|
||||
'Use a name of 1–128 characters without control characters.',
|
||||
'managed.newWorkspaceCreate': 'Create workspace',
|
||||
'managed.newWorkspaceCreating': 'Creating workspace…',
|
||||
'managed.newWorkspaceUncertain':
|
||||
'The creation is not yet confirmed. Retry to check the same workspace.',
|
||||
'The creation is not yet confirmed. Retry to check the same workspace, or abandon local confirmation to start again. Abandoning does not delete a workspace that may already exist.',
|
||||
'managed.newWorkspaceResume': 'Resume workspace creation',
|
||||
'managed.newWorkspaceClose': 'Close',
|
||||
'managed.workspaceUnsupported':
|
||||
|
|
@ -4669,13 +4675,20 @@ const ZH: Messages = {
|
|||
'managed.approvalOptionsLoadFailed': '加载审批策略失败。',
|
||||
'managed.approvalOptionsInvalid': '服务返回了不支持的审批策略。',
|
||||
'managed.approvalFixed': '当前任务的审批策略已固定;如需更换,请新建任务。',
|
||||
'managed.newWorkspaceDescription': '为托管任务创建一个空工作区。',
|
||||
'managed.newWorkspaceDescription':
|
||||
'选择已有项目目录,或输入名称创建空工作区。',
|
||||
'managed.workspaceDirectoryRoot': '已授权的 Linux 目录',
|
||||
'managed.workspaceDirectoryHint':
|
||||
'选择此授权根目录内、位于 Linux 执行机器上的已有项目。',
|
||||
'managed.workspaceChooseDirectory': '选择已有目录',
|
||||
'managed.workspaceDirectoryScope': '请选择已选根目录中的目录。',
|
||||
'managed.newWorkspaceName': '工作区名称',
|
||||
'managed.newWorkspaceNameInvalid':
|
||||
'名称需为 1–128 个字符,不能包含控制字符。',
|
||||
'managed.newWorkspaceCreate': '创建工作区',
|
||||
'managed.newWorkspaceCreating': '正在创建工作区…',
|
||||
'managed.newWorkspaceUncertain': '创建结果尚未确认,重试将查询同一个工作区。',
|
||||
'managed.newWorkspaceUncertain':
|
||||
'创建结果尚未确认。重试将查询同一个工作区,也可放弃本地确认后重新开始。放弃不会删除可能已经创建的工作区。',
|
||||
'managed.newWorkspaceResume': '继续确认工作区创建',
|
||||
'managed.newWorkspaceClose': '关闭',
|
||||
'managed.workspaceUnsupported': '当前服务不支持工作区绑定。',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue