diff --git a/frontend/README.md b/frontend/README.md index a2670a05d..58ef4b6d6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -264,26 +264,28 @@ See [deployment and operation](service/studio_release_notifier/README.md). AgentKit. Connected Harness agents expose supported image, video, and presentation task types; Studio mounts only missing task tools for the current session and preserves tools already supplied by the Agent. -- **Intelligent Agent development**: describe the intended VeADK Agent once and - receive immediate, cancellable preparation feedback before the development - conversation opens. Studio then automatically runs intent gating, - implementation, local checks, a temporary cloud deployment, acceptance calls, - log inspection, and cleanup. - Public Codex reasoning updates and Assistant replies use the normal - conversation renderer; credentials, raw Sandbox paths, and internal commands - stay hidden. Each build appears in the shared conversation history for the - lifetime of its remote development environment (up to eight hours); reopening - it restores the latest conversation and current source-delivery card. - Navigating away from an active build requires confirmation and stops that - build before leaving, while the conversation remains available until expiry. - Stopping preserves received output and blocks the next submission until - cleanup finishes. An interrupted Codex turn is reported explicitly, including - when Studio discovers it after reconnecting; it does not publish a new version - or wait for the inactivity timeout. Users can inspect generated text files and - download the complete ZIP (including binary assets) as soon as the source is ready. - Active turns resume on the same thread after a connection drops. New task - progress resets the recovery allowance; reconnecting and reading unchanged - state do not extend the inactivity deadline or restart the task. +- **Intelligent Agent development**: describe a VeADK Agent and receive + immediate, cancellable preparation feedback. Studio owns each build as a + background task, including implementation, local checks, temporary cloud + validation, source delivery, version persistence when configured, and cleanup. + Codex App Server **0.154.0** provides native thread/turn recovery, history, + `turn/interrupt`, and `turn/steer`. The running composer keeps separate Stop + and Add instructions controls. Additions show whether they are pending, + sending, delivered, or withdrawn; additions during recovery or delivery wait + until they can safely be applied. + Navigating away or disconnecting SSE leaves the task running. Studio's task + notification returns to it, and replay restores existing output without + duplicating messages. Only an explicit Stop requests remote interruption. + Stop is accepted immediately, remains “stopping” until confirmed by Codex and + any delivery command, and preserves all received output and workspace changes. + Temporary transport failures reconnect automatically; interrupted native turns + may continue in the same thread after checking their status. Ambiguous + submissions are reconciled by their native message identifiers instead of + being sent again. Unresolved failures retain output and expose a Continue + action; unusable environments or invalid source integrity require a new build. + Public reasoning and Assistant replies reuse the existing conversation + renderer. Credentials are redacted before events reach local storage. + Source downloads remain available once the delivery artifact is ready. Each completed build or optimization is also saved as an immutable project version in the private Studio TOS bucket. Users can reopen any saved version, view, download, deploy, delete, or restore it into a new Sandbox for another @@ -295,6 +297,30 @@ See [deployment and operation](service/studio_release_notifier/README.md). Deployable source can be sent to Runtime manually; an incomplete verification report requires an explicit confirmation. No separate “start verification” action is required. + Task execution metadata and replay events use local SQLite, with no TOS task + writes. `VEADK_STUDIO_TASK_DB` defaults to + `~/.veadk/studio/development-runs.sqlite3`; retain this directory on a local + persistent volume when restarting a container. `VEADK_STUDIO_TASK_RETENTION_SECONDS` + defaults to 21600 (six hours after a terminal state). + Active tasks are not removed by retention. `VEADK_STUDIO_TASK_MAX_ACTIVE_SECONDS` + defaults to 28800 (eight hours), after which Studio requests a durable stop. + Admission allows three unfinished tasks per user, 100 globally, and 16 local + workers. Task output is limited to 64 MiB per run; reaching the limit keeps + existing content. All task reads, event replay, controls, and deletion use the + authenticated owner and an owner-qualified database key. + This SQLite deployment supports one Studio instance with multiple users. + Replicas with separate local disks do not share task state; moving to multiple + instances requires a shared transactional task store. Losing the local volume + loses its short-term task history. Existing immutable project/version storage + continues to use the configured TOS repository independently. + The task API accepts idempotent submissions under + `/web/intelligent-development/sessions/{sessionId}/runs`, exposes ordered replay + at `/runs/{runId}/events?after={seq}`, and accepts `/stop`, `/resume`, and `/inputs`. + `/runs` lists only the current user's unfinished tasks. The legacy `/messages` + stream is an adapter over the same task service and includes `run.status` + events; stream detachment does not stop work. + See the [verification report](recoverable-build-verification.md) for executed + recovery, isolation, Stop/Steer, and retained-output checks and their limits. - **Existing Agent migration**: upload a local project ZIP for read-only analysis, confirm the detected framework and entry point, then migrate and validate it in a temporary Sandbox. Successful migration source is saved as @@ -1404,3 +1430,48 @@ DatePicker 在基础组件中展示日期与日期时间选择;IndexLayout 在 `npm run dev:components` 启动独立组件库,在布局分组打开 App layout 可查看完整侧栏 支持 240px / 56px 展开折叠、会话菜单、账号区域与默认深色的主题切换 全屏入口为 `/components-preview/?fullscreen=app-layout#app-layout`,详见 [组件预览说明](src/components-preview/README.md) + +### 智能构建首页与任务找回 + +首页将新建构建与进行中的任务并列展示,已保存项目位于下方。窄窗口优先展示进行中的 +任务。列表通过已有后台任务监控读取当前登录用户的任务,显示需求摘要、状态和开始 +时间;刷新或重新打开网页后仍可找到尚在运行、重连中或等待回复的任务。 + +点击「查看任务」会连接原开发环境,恢复已保存输出及停止、追加要求等操作,不会 +重新创建构建。任务列表与后台通知共用一套查询;首页内不重复弹出后台任务提示。 +网络异常保留已显示的列表和正在编辑的需求,可手动重试;切换账号后隔离旧数据。 +已结束任务的构建产物仍在「已保存项目」中,短期记录受现有保留时间和实例生命周期限制。 + +浏览器回归脚本 `scripts/checkDevelopmentTurnUi.mjs` 支持 `CHECK_HOME=1`,覆盖服务端 +发现、重新加载、打开失败重试、过期导航响应和原输出恢复;`CHECK_LOCALE=en-US` +可验证英文页面。测试使用受控 Sandbox HTTP,不会调用真实模型。 + +### 智能构建的过程与每轮统计 + +开发环境准备、连接期间,在当前状态旁显示「取消」。取消后保留需求、模型和所选 +项目版本,返回原输入位置;已经取消的请求即使稍后返回,也不会进入执行页或显示旧错误。 +此阶段不显示执行输入框;真正开始构建后,输入框提供停止任务和追加要求操作。 + +智能构建按 Codex 原生 turn 统计;同一轮中的 steer 补充消息不重新计时。 +思考、工具、计划和文件变更使用带图标的可展开过程行,命令行标题显示短英文摘要, +完整命令与输出保留在详情中。失败命令保留原位,单个工具不显示执行耗时。 +分组和整轮统计保留汇总耗时,自动使用毫秒、秒、分和小时; +例如 `1,542,277 ms` 显示为 `25 分 42.3 秒`,显示精度下为零时显示为 `<1 毫秒`。 +耗时单位跟随界面语言:中文使用毫秒、秒、分、小时,英文使用 ms、s、min、h。 +该展示规则不修改原始耗时数据或累计计算,缺少耗时数据时仍显示“未上报”。 + +每轮成功、失败或中断后显示工具调用次数、本轮耗时、工具累计耗时和 Tokens 按钮。 +次数按原生工具 item 去重,包含失败和中断调用;工具累计耗时是各调用耗时之和, +并行调用可能使其超过本轮耗时。缺少部分工具耗时时,显示“已记录工具耗时”。 +Tokens 使用带展开提示的轻量文字按钮,可通过悬浮、键盘聚焦或点击查看本轮模型、输入、输出、缓存命中与未命中、 +缓存写入及推理输出。缓存命中是输入的子集,推理输出是输出的子集,不重复累加; +未命中输入为输入减缓存命中。缺失指标显示“未上报”,中断或统计断点显示记录可能不完整。 + +产物卡片以 Agent 名称为标题,入口独占一行并支持长路径换行,文件数和大小并排, +时间与验证结果单独展示。查看源码、比较和下载位于辅助操作区,部署单独突出; +窄窗口将操作区上下排列,保留加载、错误和重试反馈。 + +统计沿用按用户隔离的 SQLite 短期保存和事件回放。`run.turn` 是新增事件,原有 +`usage` 事件仍保留兼容;已有数据库只增加 `run_turns.metrics` 列。回滚到旧代码时应 +使用新的短期数据库路径,因为旧版本按固定列数写入 `run_turns`。当前单实例云部署 +在实例替换后丢失短期记录的约定不变。 diff --git a/frontend/build-ux-verification.md b/frontend/build-ux-verification.md new file mode 100644 index 000000000..b3ce2db8c --- /dev/null +++ b/frontend/build-ux-verification.md @@ -0,0 +1,78 @@ +# Recoverable build presentation verification + +This change keeps task execution independent from the browser and presents Codex 0.154.0 items as a stable conversation. The recoverable task service, native stop/steer, SQLite retention and owner authorization are described in [recoverable-build-verification.md](recoverable-build-verification.md). This report covers the subsequent presentation and event changes plus integration with the updated main branch. + +## Gates + +| Gate | Status | Evidence | +|---|---|---| +| Change Basis | PASS | Original worktree at `d2ce736821052de81602b422669ae7b5e82430a6` preserved. Changes applied cleanly onto `6dcc022c48e95fad75e9a0e3c90e6071d67f5b78` after fetching/pulling main, on `feat/studio-recoverable-build-ui`. | +| Contract | PASS | Six requested behaviors mapped below; additive item metadata and event payloads, with existing clients retained. | +| Impact | PASS | Native App Server → public redaction → task runner/SQLite → SSE → projection/hook → both Block consumers traced. | +| Portfolio | PASS | Protocol, redaction, projection, hook race, disclosure DOM, real Chromium and cloud verification boundaries selected. | +| Baseline | PASS | Observed four backend regression failures and three frontend projection failures before implementation. Original source hashes preserved; actual prior uploaded wheel matched all five changed backend modules. | +| Execution | PASS | 498 affected Python tests and 1181 frontend tests passed; build, asset references, i18n and repository-pinned pre-commit checks passed. | +| Evaluation | PASS | Tested error, replay, owner switch, stop, steer, empty thinking, long output and expanded state. Browser limitations stated below. | +| Handoff | PASS | Native update, three Function releases/timers, Identity and resource checks passed; actual uploaded wheel/source/public assets verified; commit/push receipt retained. | + +## Contract and impact + +| Change ID | Changed contract | Affected surface | Repository evidence | Failure mode | Risk | +|---|---|---|---|---|---| +| C01 | Start immediately displays the request and preparation; accepted request ID merges into subscription | App start flow and task hook | `App.tsx`, `useDevelopmentRun.ts`, `developmentRuns.ts` | Empty discovery clears the optimistic request; 1.5s discovery delay; stale owner/session response | R01 High | +| C02 | Current execution status belongs to the assistant/process row; generic busy never implies reasoning | Active transcript and composer | `DevelopmentProcess.tsx`, `Blocks.tsx`, `App.tsx` | Duplicate/misplaced status; false thinking indicator | R02 Medium | +| C03 | Native command actions, file changes and MCP names carry meaning; errors/duration/output survive | Protocol adapter, public payload and tool renderer | `codex_app_server.py`, `frontend_sandbox.py`, `intelligent_development_routes.py`, `sandbox.ts` | Failed command rendered successful; tool output lost; every action looks identical | R03 High | +| C04 | Empty reasoning start is visible; public summary parts remain separate from raw reasoning | Native notifications and thinking blocks | `_handle_notification`, `createSandboxProjection` | No thinking indicator until text arrives; duplicated raw/summary text | R04 Medium | +| C05 | Consecutive process items fold between assistant messages; expansion persists during updates and replay | Process grouping and stable item keys | `developmentPresentation.ts`, `DevelopmentProcess.tsx`, `Blocks.tsx` | Flicker, remount, hidden failures or lost expanded state | R05 Medium | +| C06 | Tool output/progress, plan, diff and meaningful thread status update their existing items | Runner, SSE and frontend projection | `runner.py`, `developmentRuns.ts`, `sandbox.ts` | Event loss/duplication; per-token whole-history render cost | R06 High | +| C07 | All new public content crosses redaction before persistence; user boundaries and stop intent remain enforced | Task text/output projection and task service | `output.py`, `repository.py`, `routes.py`, `useDevelopmentRun.ts` | Split credentials leak; cross-user data or interrupted request continues | R07 High | +| C08 | Rich process content remains usable with keyboard, Chinese IME, narrow layout and reduced motion | Built App and shared Block union consumers | `DevelopmentProcess.css`, `SandboxComposer.tsx`, `StudioConversation.tsx` | Overflow, keyboard-only disclosure inaccessible, premature submit, unsupported new Block | R08 Medium | +| C09 | Existing correct-account site receives the tested source and retains Identity, single instance and Sandbox bindings | Native `studio update`, main/scanner/worker/timers | Deployment receipts and actual uploaded wheel | Wrong account, changed shared Tool config, stale public assets | R09 High | + +## Test portfolio + +| Risk ID | Boundary | Method | Cases | Oracle | Evidence | Status | +|---|---|---|---|---|---|---| +| R01 | Hook + actual observer + built App | Controlled HTTP race tests and Chromium | Slow create/connect; empty discovery; retry; owner switch; immediate seed | Request remains visible without a welcome frame; same request ID; no stale owner update | `useDevelopmentRun.test.mjs`, `developmentRuns.test.mjs`, `browser.json` | VERIFIED | +| R02 | Actual transcript | DOM + visual inspection | Preparing, thinking, MCP progress, recovery, stopped | Active process reflects actual event; composer remains input/control area | `developmentProcess.test.mjs`, screenshots | VERIFIED | +| R03 | Native protocol/public events/Block rendering | Contract + integration + browser | Read/list/search/file/MCP metadata; failed command; exit code; duration; live log | Native identity and failure retained; live log end is visible | `test_codex_presentation_events.py`, projection tests, expanded screenshot | VERIFIED | +| R04 | Native reasoning notification and projection | Regression tests + Chromium | Empty start; raw followed by multiple summary parts; final snapshot | Immediate “正在思考”; one public summary | Four observed backend RED cases → GREEN, projection regression | VERIFIED | +| R05 | Disclosure + projection | DOM updates and real keyboard interaction | Stream, completion, recovery, assistant split, hidden failure summary | Same disclosure node; manual expansion unchanged; failure visible when collapsed | `developmentProcess.test.mjs`, browser journey | VERIFIED | +| R06 | SSE and item projection | Packet replay and update tests | 100 text frames; duplicate seq; authoritative snapshots; plan/diff replacement | One packet causes bounded UI updates; one item per identity | `developmentRuns.test.mjs`, browser journey | VERIFIED | +| R07 | Streaming redaction, SQLite service, hook | Split-boundary, authorization, stop/race tests | Every secret split; tool vs assistant channel; ownership; stop during create/navigation; steer ack | No partial credential reaches public frames; no foreign data; stop remains effective | `test_development_run_output.py`, run service/repository/routes/runner tests, hook tests | VERIFIED | +| R08 | Browser and TypeScript union consumers | Chromium + build + composer DOM | 1440×960/820×900; long logs; keyboard disclosure; IME Enter; stop/steer; reduced motion | No page errors/overflow; input preserved; both renderers support diff | `browser.json`, 1181 frontend tests, TypeScript build | VERIFIED | +| R09 | Correct-account cloud resources and uploaded package | Native update, readback and HTTP byte comparison | Identity, bound tools, replicas, task DB, three functions/timers, source and assets | Existing URL live; expected resources unchanged; exact tested content deployed | `preflight.json`, `final.json`, `cloud-final.json`, `http-final.json` | VERIFIED | + +## Executed evidence + +Python: `PYTHONPATH=. /data00/home/wujiaming.ai/workspace/github/veadk-python/.venv/bin/python -m pytest` with the following affected paths: + +- `tests/cli/test_codex_app_server.py`, `test_codex_presentation_events.py`, `test_frontend_sandbox.py`, `test_studio_update.py` +- `tests/frontend/server/test_development_run_output.py`, `test_development_runner.py`, `test_development_run_routes.py`, `test_development_run_service.py`, `test_development_run_shell.py`, `test_intelligent_development_runs.py`, `test_intelligent_development_routes.py`, `test_intelligent_development_source.py`, `test_intelligent_development_projects.py`, `test_sandbox_remote.py` + +Result: **498 collected/executed/passed, 0 failed/skipped**. Five dependency deprecation warnings. Log: `/tmp/studio-ux-integrated-python.log`. + +Frontend: `npm test`: **1181 collected/executed/passed, 0 failed/skipped**. Log: `/tmp/studio-ux-frontend-final.log`. `npm run build` passed TypeScript and both Vite bundles. `npm run test:webui-assets` verified **104 packaged files and 248 references**. `npm run check:i18n` verified **2 locales and 21 namespaces**. `uvx pre-commit run` passed repository-pinned Ruff check, Ruff format and Gitleaks with staged source, new modules, tests and assets. + +The first affected Python run exposed an old test that expected raw reasoning to append to summary; its expected sequence was corrected to the newly declared protocol contract. Two old frontend exact-object assertions were updated to include retained item IDs and completion status. Their original content/order checks remain. New tests still demonstrate the originally observed failures. Browser harness locator/ack-timing mistakes were corrected; they are not counted as product passes. + +The unpinned global Ruff invocation used unrelated ambient rules and reported pre-existing diagnostics; the actual repository-pinned pre-commit checks above passed. A Pyright check of the five backend presentation modules retained one identical baseline diagnostic in pre-existing Sandbox environment typing, with no new diagnostic after the duration type fix. The system Python lacks SQLite; the selected project interpreter was used for Python tests. + +Browser evidence uses actual built Studio served locally and Chromium 1187, with controlled Sandbox/task HTTP responses to exercise slow startup, native event ordering and transport failure deterministically. The actual App, HTTP client, hook, SSE parser, projection, renderer and composer run without mocks. This is not a claim of a new real model build. Earlier real App Server/Sandbox interruption/recovery evidence remains separately documented in `recoverable-build-verification.md`. + +Final browser observations: **4ms** from create response to event subscription; 28 startup samples retained the user request without a welcome frame; the user bubble remained at y=28px across preparation and execution; keyboard disclosure, retained expansion, 150-line live output, plan/diff, recovery, steer, stop and Chinese IME passed; **0 page errors**; **820px scroll width at an 820px viewport**. Screenshots were visually reviewed. The sampled delay is a local controlled measurement, not a cloud latency guarantee. + +Evidence directory: `/data00/home/wujiaming.ai/workspace/reports/studio-build-ux-20260915/`, including `baseline-manifest.json`, `browser.json`, `preparation.png`, `initial-thinking.png`, `expanded-desktop.png`, `stopped-narrow.png` and cloud receipts. Browser script: `/tmp/studio-recoverable-browser/ux-journey.mjs`. + +## Limits + +Safari/Firefox and a real cloud network partition were not exercised for this UI change. Browser testing used Chinese and the existing light appearance; locale catalog parity and the normal suite passed, but this is not a full theme/locale visual matrix. Cloud Identity login and a new paid model build are not implied by public HTTP/resource verification. SQLite is intentionally local/ephemeral for six-hour task retention; the user accepted loss of task records when the single cloud instance is replaced. These limits do not weaken server-side owner authorization. + + +## Deployed result + +Native `studio update` completed successfully for `veadk-studio-recoverable-0915` in Volcengine `cn-beijing/default`, account `2116711330`. Existing application `d7014d0f55f7` and URL `https://snm4e0bjff3hs4mdvnuh6.apigateway-cn-beijing.volceapi.com` were retained. Main Function `a7yabcrg` is revision **4**; scanner `ah049y3m` and worker `82flpzkg` are revision **2**, with their existing enabled minute timers verified. Min/max instances remain **1/1**. Identity pool/client, Dev Sandbox binding, six-hour SQLite settings and six shared Tool configurations were read back and verified. + +The actual native wheel is `veadk_python-1.1.14.dev3+g6dcc022c4.d20260915-py3-none-any.whl`, SHA256 `6f00e60aff5d0327b64f8fcad1d4644f5e86da11f889a8b61471f6eee98b6d65`. Fourteen changed runtime Python files match the source byte-for-byte. Public HTML, entry JavaScript, CSS, logo and widget match this wheel; Identity redirect and unauthenticated access protection passed. Generated WebUI files committed with this change were taken from that exact uploaded wheel. The native builder sets its release metadata and produced a different entry hash from the earlier ordinary local build, so the actual native artifacts were also checked locally instead of treating an independently rebuilt bundle as uploaded evidence. + +All Risk IDs R01–R09 are VERIFIED within the boundaries stated above. No material test gap is left unexplained; the browser/provider limits above still apply. diff --git a/frontend/recoverable-build-verification.md b/frontend/recoverable-build-verification.md new file mode 100644 index 000000000..eccfeab93 --- /dev/null +++ b/frontend/recoverable-build-verification.md @@ -0,0 +1,178 @@ +# Recoverable intelligent-build verification + +Comparison base: `d2ce736821052de81602b422669ae7b5e82430a6` (`origin/main` at branch creation). Branch: `feat/studio-recoverable-build`. Verification date: 2026-09-15. No commit, push, PR, or production deployment is part of this handoff. + +## Gate Status + +| Gate | Status | Evidence or blocking reason | +|---|---|---| +| Change Basis | PASS | Isolated worktree; tracked changes and new source/test files inspected; original checkout left intact. | +| Contract | PASS | Task lifetime, native execution, ownership, retained output, Stop/Steer and expiry contracts below. | +| Impact | PASS | R01–R10 cover execution, data, native APIs, publication, UI and packaged consumers. | +| Portfolio | PASS | SQLite integration, native adapter contracts, subprocess fault tests, DOM/hook tests and real Sandbox/browser journeys. | +| Baseline | PASS | 258 baseline tests; recorded failures for recovery, output, lifespan and version retry; comparison-base type checks. | +| Execution | PASS | Backend/frontend suites, build, native final-state checks, browser journeys and scans executed. | +| Evaluation | PASS | State/side-effect oracles verified; unexecuted environments and pre-existing diagnostics identified below. | +| Handoff | PASS | All Risk IDs have a final status; configuration, evidence and operational limits are documented. | + +## Change Basis + +The accepted design uses short-term local SQLite for task state and event replay, with no TOS task storage. Existing immutable source-project storage remains independent. The implementation targets one Studio instance, multiple authenticated users and Codex App Server 0.154.0. SQLite's directory must survive Studio restarts. + +The worktree contains backend task/repository/runner/control changes, the existing Sandbox and Codex adapters, Studio composer/observation/notification integration, tests, image pins, documentation and matching `veadk/webui` artifacts. Unrelated modifications in the original checkout were not copied or changed. + +Commands were discovered from `AGENTS.md`, `frontend/SPEC.md`, package scripts, `.pre-commit-config.yaml` and CI. The executable Python is `/data00/home/wujiaming.ai/workspace/github/veadk-python/.venv/bin/python` (3.12); the system Python has no SQLite support. Browser checks use Chromium 1187 and temporary Playwright tooling outside the repository. No dependency was added for task storage or browser verification. + +## Observable Contracts + +| Change ID | Old behavior | New behavior | Inputs/outputs/state | Side effects | Error modes | +|---|---|---|---|---|---| +| C01 | HTTP/SSE request owned build execution | Background run survives subscriber loss and process restart | Durable run, input revision, events, checkpoints and lease | Reattach exact native thread/turn | Recovery status; unresolved state waits for user without discarding output | +| C02 | Some environment access depended on local connection state | Fresh owner-scoped environment resolution | Authenticated owner is required for run operations | Only authorized environment receives native calls | Foreign IDs return 404; no admin ownership bypass | +| C03 | Stream errors appeared as stopped/failed tasks | Native retry/reconciliation distinguishes connection loss from terminal execution | `thread/resume`, turn history/read, native `willRetry` | Resume observation; bounded continuation only after a confirmed terminal turn | Ambiguous acceptance is never blindly resent | +| C04 | Stop was coupled to stream cancellation; sending while busy was disabled | Independent durable Stop and native Steer | Requested and confirmed stop differ; ordered input revisions | `turn/interrupt`; `turn/steer` with expected turn | Stop remains pending until confirmed; ambiguous Steer is reconciled | +| C05 | Publication identifiers could change after retry | Deterministic delivery/version identity and remote receipt | Fixed IDs, digest checks, resumable publication stage | Existing successful effects are reused | Conflicting artifact digest is rejected; old retry cannot rewind binding | +| C06 | Error could clear partial browser output | Append/replay by sequence and native item identity | Rich blocks retained across error, navigation and refresh | Short-term SQLite event writes | Missing/gapped stream reconnects; terminal expiry is explicit retention behavior | +| C07 | Navigation lost task visibility | Global owner-scoped task notice and return action | Editable composer, separate Stop, accepted/queued input feedback | Navigation detaches only | Stale owner/session callbacks cannot overwrite the active view | +| C08 | No durable short-term run lifecycle | Terminal retention defaults to 6 h, max active life 8 h | Configurable local DB/retention/lifetime; active work is not TTL deleted | Expired terminal rows cleaned; overdue work gets durable Stop | Per-user/global/input/event limits reject excess admission safely | +| C09 | Image used an older Codex binary | Runtime pinned to 0.154.0 | x64 and arm64 asset digests and executable member paths | Image installer uses pinned binary | Checksum/member validation; actual x64 binary version verified | +| C10 | Existing `/messages` and rich Sandbox consumers | Compatibility adapter delegates to durable task service | Existing delivery/usage/activity consumers remain | Matching bundled frontend | Terminal run status replaces request-level failure for recoverable tasks | + +## Change Impact Traceability + +| Change ID | Changed contract | Affected surface | Repository evidence | Failure mode | Risk | +|---|---|---|---|---|---| +| C01 | Detached execution and restart | Studio lifespan, scheduler, SQLite leases | `intelligent_development_runs/service.py`, route lifespan wrapper | Restart leaves work unobserved or starts duplicate worker | R01 High | +| C02 | User isolation and redaction | Run APIs, session resolver, SQLite and event output | `routes.py`, owner-qualified repository queries, `output.py` | Cross-user read/control or credential exposure | R02 High | +| C03 | Native reconciliation | `codex_app_server.py`, runner | Exact association, native status precedence and client-message correlation | Duplicate start, wrong thread, premature completion | R03 High | +| C04 | Stop/Steer races | Runner control loop, repository inputs, hook/composer | Durable stop before interruption; expectedTurnId | Stop lost on refresh; duplicate/misrouted input | R04 High | +| C05 | Resumable publication | RunShell, DeliveryPublisher, project service | Remote lock/started marker/receipt; deterministic version | Repeat external operation or rewind version binding | R05 High | +| C06 | Output preservation | Text projection, event storage and rich renderer | Stable suffix deltas; snapshot upsert; cursor validation | Cleared/duplicated output, split-secret leak, quadratic storage | R06 High | +| C07 | Task UI and identity | App, useDevelopmentRun, DevelopmentTaskNotice, SandboxComposer | Owner/session guards; existing Toast/Button | Wrong user's transcript; disabled interruption; layout overlap | R07 High | +| C08 | Retention and capacity | Repository admission/cleanup and scheduler | Limits, active/waiting lifecycle, WAL | Delete live work, unbounded growth, stale lease writes | R08 Medium | +| C09 | Codex version | Sandbox image assets and installer | Pinned asset URLs/digests and native schema | Installer failure or unavailable native control API | R09 Medium | +| C10 | Existing consumers | Source preview, legacy messages, ordinary Sandbox, packaged assets | Affected Python/frontend suites and build | Break deployment/source access, IME, imports or bundle references | R10 Medium | + +## Test Portfolio Traceability + +| Risk ID | Boundary | Method | Cases | Oracle | Evidence | Status | +|---|---|---|---|---|---|---| +| R01 | Actual SQLite + service lifespan + real Studio | Integration and process fault injection | Disconnect, shutdown, hard restart, lease takeover, custom lifespan | Same run/thread/turn completes; no duplicate native turn | Service/repository/routes tests; clean live run 5 | VERIFIED | +| R02 | HTTP owner resolution + real DB + streamed text | Authorization and sensitive-data fault cases | Foreign list/read/stream/stop/steer/delete, replay, split secret and credential rotation | No cross-owner content/effects; secrets absent from stored/public events | Run API/repository/output tests; secret scans | VERIFIED | +| R03 | Codex wire adapter and runner | Contract/fault injection + native live execution | Lost start response, `willRetry`, history pagination, inProgress commentary, exact attach | No resend of unknown input; explicit native status wins | Codex tests; runner unknown-submission cases; live run 5 | VERIFIED | +| R04 | DB/control/native API + browser | Race tests, DOM/hook tests, native interruption | Stop during submit/navigation, pending Steer withdrawal, late ack, same-turn Steer | Accepted intent persists; native turn interrupted; retained output | Runner/repository/hook/composer tests; live runs 4–6 | VERIFIED | +| R05 | Real subprocess wrapper + project service | Lost-response/stop fault tests and storage failure injection | Duplicate wrapper, delayed submission, process-group stop, commit/binding failure, old retry | Side effect once; receipt authoritative; one version; latest binding preserved | `test_development_run_shell.py`; project service regression | VERIFIED | +| R06 | Backend and browser projection | Stream/property-size tests and live replay | Split tokens, commentary snapshot, late ack, sequence gap/duplicate, reconnect | Exact content retained, same item upserted, linear event growth | Output/frontend projection tests; live before/after SSE and screenshots | VERIFIED | +| R07 | Actual React hook/composer and Chromium | DOM, race integration, visual/interaction review | IME Enter, steer while running, sending/stopping, error/resume, refresh, navigation | Correct callback/disabled behavior, no stale identity update, no overlapping task notice | Hook/composer tests; browser journeys | VERIFIED | +| R08 | SQLite/time boundaries | Integration with controlled clock and limits | Terminal TTL, waiting/active retention, admission limits, lease expiry, finish/stop race | Live records retained; excess work rejected; expired writer fenced | Repository and service tests | VERIFIED | +| R09 | Actual archives/binary and installed SDK | Checksum/member/response-contract validation | x64/arm64 downloads, x64 version, native SetSessionTtl response | Digests and members match; binary 0.154.0; returned expiry used | Image validation; renewal contract; actual managed DevEnv | VERIFIED | +| R10 | Transitive consumers/build pipeline | Affected suite, full frontend suite, static/build checks | Source access/integrity, compatibility delivery, composer consumers, packaged imports | Assertions pass; no new type diagnostics; all asset references resolve | 549 Python tests; 1173 frontend tests; build/assets/Ruff/Pyright | VERIFIED | + +## Baseline Evidence + +The original affected baseline passed 258 tests (`/tmp/studio-recoverable-build-baseline.xml`). Observed RED→GREEN cases include unsent Steer incorrectly acknowledged during resume, withdrawn input resurrection, status cursor mismatch, identity collisions after message redaction, cumulative-output quadratic growth, native `inProgress` commentary incorrectly treated as terminal, and a custom Studio lifespan that did not start the recovery scheduler. + +Additional executed regressions found during completion: first deterministic version lookup caught the wrong NotFound subtype; a late retry could rewind the binding to an older version. The first failed in `/tmp/studio-runs-persistence-renewal-red.log`, the second in `/tmp/studio-runs-persistence-order-red.log`; the retained service test now passes. + +Live run 1 required a manual correction to pre-fix test data and is **not** evidence of uninterrupted recovery. Live run 2 exposed the lifespan issue. Clean live run 5 was created after these fixes, forcibly interrupted only at the Studio process, automatically resumed and completed without editing its task data. Run 4 independently confirmed real native interruption after a browser Stop. + +## Execution Result + +Commands below run from the worktree unless `frontend/` is stated. `$PY` denotes the venv Python described above. + +| Command | Collected | Executed | Passed | Failed | Skipped | What it proves | +|---|---:|---:|---:|---:|---:|---| +| `$PY -m pytest` with the affected files listed below, `-q --tb=short --junitxml=/tmp/studio-runs-full-impact-final.xml` | 549 | 549 | 549 | 0 | 0 | Backend impact scope and image runtime helpers | +| Final focused project-retry and native-renewal tests after test typing cleanup | 2 | 2 | 2 | 0 | 0 | Typed fixtures retain the same runtime assertions | +| `cd frontend && npm test` | 1173 | 1173 | 1173 | 0 | 0 | Full existing frontend test suite including new hook/DOM/projection cases | +| `cd frontend && npm run build` | — | 1 | 1 | 0 | — | TypeScript and both Vite production bundles | +| `cd frontend && npm run test:webui-assets` | — | 1 | 1 | 0 | — | Packaged file/reference validation | +| `uv tool run --from ruff==0.11.12 ruff check --config pyproject.toml $(cat /tmp/studio-changed-python.txt)` | 27 files | 27 | 27 | 0 | 0 | Changed Python lint | +| `uv tool run pyright --pythonpath "$PY" $(cat /tmp/studio-changed-python.txt)` and comparison-base run | 27 files | 27 | — | 75 existing diagnostics | 0 | No added diagnostics; see comparison record | +| Repository-configured Gitleaks; additional default-rule scan including tests and bundles | — | 2 | — | 0 credential findings | — | Repository scan clean; supplemental scan has one verified vendor-code false positive | +| Source-only `git diff --check` | — | 1 | 1 | 0 | — | No whitespace errors in maintained source | +| Prettier Markdown parser over the three changed Markdown files | 3 | 3 | 3 | 0 | 0 | Markdown parses; README/report link targets resolve | + +The Python impact command includes: + +```text +tests/frontend/server/test_intelligent_development_routes.py +tests/frontend/server/test_intelligent_development_task.py +tests/frontend/server/test_intelligent_development_projects.py +tests/frontend/server/test_intelligent_development_source.py +tests/frontend/server/test_intelligent_development_runs.py +tests/frontend/server/test_development_runner.py +tests/frontend/server/test_development_run_routes.py +tests/frontend/server/test_development_run_output.py +tests/frontend/server/test_development_run_service.py +tests/frontend/server/test_development_run_shell.py +tests/frontend/server/test_sandbox_remote.py +tests/cli/test_codex_app_server.py +tests/cli/test_frontend_sandbox.py +tests/cli/test_frontend_sandbox_options.py +tests/cli/test_frontend_sandbox_proxy.py +tests/cli/test_studio_sandbox_tools.py +frontend/sandbox-image/tests/test_runtime.py +``` + +No cases were skipped/deselected in this selected command. One existing Starlette/AnyIO deprecation warning was reported. Initial 62 backend failures were traced to 61 obsolete source-preview fakes using the old session interface and one old commentary event assertion; the fake interface was migrated while retaining ownership/archive/integrity checks, and the assertion was migrated to the single-item text projection. Two additional hook regressions cover losing both the initial submission and Stop response, and losing a deferred Stop after submission acceptance. Before the fix they permanently locked submission or left Stop disabled. They now permit retry while retaining the input request ID and its Stop intent (`/tmp/studio-runs-stop-retry-red.log`, `/tmp/studio-runs-stop-deferred-red.log`, and the final frontend suite). The initial composer DOM failure was an incomplete translation mock. Subsequent source-consumer assertions were updated after removal of the unreachable request-owned intelligent-build path. These failures were investigated, not retried away. + +## Coverage and Oracle Quality + +This is an affected-scope verification, not a claim that the entire Python repository or every cloud provider was tested. The repository's unrelated sidecar coverage gate does not apply to this task path; no new coverage percentage was invented. Core failure modes have state and side-effect assertions, including exact native turn association, publication count, owner-qualified access, stop confirmation, event ordering and output size. Reintroducing those defects fails the corresponding retained regressions. + +The final Pyright run reports 75 diagnostics, identical to the comparison base by file, source statement and diagnostic message. `/tmp/studio-runs-pyright-comparison.json` records zero added/removed diagnostics. Seventy-four are existing dynamic-test typing issues; one is the unchanged `envs` request boundary in `frontend_sandbox.py`. New task package code has no type diagnostics. Full TypeScript compilation passes. + +Gitleaks with repository configuration found no leaks. The supplementary scan deliberately included normally excluded tests and generated bundles. Its single hit is a minified editor selection expression accessing `anchor.key` and `focus.key`, with no string literal; the identical expression exists in the base bundle. No scanner rule or allowlist was relaxed. Raw scan output is redacted. + +Generated `website-integration.js` contains grammar strings with embedded whitespace; source-only diff checks pass. Generated bundles are validated through the production build and asset-reference verifier, without modifying third-party grammar strings. + +## Cross-Boundary and Non-Functional Evidence + +The real Tool is `t-yev5151m9stkidoa8h58` (`studio-recoverable-20260915-dev`), an independent Ready DevEnv in cn-beijing with snapshots disabled. No existing Tool was modified. The dedicated session is `s-yev51pzx8gzn6n5in25b`, owned by the local verification identity. Studio listens only on `127.0.0.1:18080`. No credentials or authenticated endpoints are included in this report. A separate local user was also tested against the running Studio: its task list was empty, and run detail, event replay, Stop, Resume, Steer and delete all returned 404. Receipt: `/tmp/studio-recoverable-live/owner-isolation-receipt.json`. + +Clean run 5: `2801807c67bc48ba92a55585f73809fc`; native thread `01a0a4a9-f8c4-7303-b3f0-b626ce9cf84e`; turn `01a0a4c7-43ff-7fe0-a4ed-acb1499c8dff`. It survived a SIGKILL of Studio while an actual `sleep 180` command was active, was reclaimed after the SQLite lease expired, accepted Steer in the same native turn, and completed successfully. One native turn and two delivered inputs were recorded. Output markers before and after restart remain replayable. Receipt: `/tmp/studio-recoverable-live/restart-receipt.json`. + +Final run 6 (`b9bfa8a1364f479f8d3dff9762bd0f38`) was stopped from the Chinese browser UI. Studio reached `cancelled`; an independent App Server read confirmed native turn `01a0a4cc-ade6-7e43-ab2d-ef56d1fec2eb` was `interrupted`. The same verification independently confirmed run 5 was `completed` and the remote binary was `codex-cli 0.154.0`. See `/tmp/studio-recoverable-live/native-final-receipt.json`. The browser retained earlier output and an unsent IME draft, recorded zero page errors and no horizontal overflow at 820×900. Screenshots: `/tmp/studio-recoverable-browser/final-zh-stopped-desktop.png` and `final-zh-stopped-narrow.png`. + +Native shell tests execute the remote wrapper locally as real subprocesses and verify command effects are not repeated after a lost response, delayed submission cannot bypass Stop, and stopping a running command terminates its process group and writes a receipt. SQLite tests verify bounded admission and event retention. A size regression ensures text storage grows linearly rather than storing every cumulative prefix. + +Both Codex archives match their pinned checksums and installer member paths. The x64 executable reports `codex-cli 0.154.0`; the managed DevEnv also reported 0.154.0. The arm64 executable was inspected, not executed on this x64 host. Native SDK models and the SetSessionTtl request/response were exercised in a contract test; the live session was not near expiry, so real control-plane renewal was not induced. + +## Frontend Verification + +The main Studio canvas uses the existing light theme, including when the operating system prefers dark mode. The composer, rich-block renderer, Toast and Button are reused; no new visual shell or design system was introduced. Desktop and narrow-window checks assess readable wrapping, separate accessible Stop/Steer actions and disappearance of the off-page task notice when opening its conversation. + +| Surface or consumer | State and data | Viewport or runtime | Method and oracle | Executed evidence | Result | +|---|---|---|---|---|---| +| Task composer | Running, sending, stopping, error, resume, IME | React/JSDOM | IME Enter does not send; Enter/Steer sends once; independent Stop remains available | `developmentComposer.test.mjs` | PASS | +| Task hook | Submission/Stop/navigation/identity races | React/JSDOM | Request ID reused on ambiguous failure; old callback cannot overwrite current user/session | `useDevelopmentRun.test.mjs` | PASS | +| Task replay | Duplicate/gap/late ack/commentary | Node projection integration | Stable item IDs and exact sequence produce retained nonduplicated blocks | `developmentRuns.test.mjs` | PASS | +| Active build | Reconnected native turn and old output | Chromium, en-US, 1440×960 | Marker survives navigation/reload, editable input, no overlapping task notice | `final-running-desktop.png`, restart browser receipt | PASS | +| Active build | Narrow viewport, long Chinese content | Chromium, 820×900 | Text and controls wrap without overlap/horizontal clipping | `final-running-narrow.png` | PASS | +| Stop | User requests native interruption | Real Sandbox + Chromium | Backend cancelled and native interrupted; preceding text retained | Run 4 and run 6 native receipts; final Chinese screenshots | PASS | + +The first final-browser attempt loaded a blank page because the local Studio process cached HTML before the final asset rebuild. The existing server loads its entry HTML at startup; restarting that local process after the completed build resolved the test-environment mismatch. The native stop verifier was initially invoked before this browser could submit Stop and correctly rejected the still-running turn. These failed attempts are not counted as passing Stop evidence. + +Playwright is temporary tooling in `/tmp/studio-recoverable-browser`; screenshots and receipts stay outside source. These are visual inspections, not a pre-existing screenshot-diff baseline. Safari/Firefox, a full arm64 runtime, multi-instance deployment and actual cloud-region outages were not executed; related uncertainty remains bounded by protocol/DOM tests and the single-instance support boundary. + +## Residual Risk + +| Risk ID | Inherent level | Executed evidence or control | Remaining uncertainty | +|---|---|---|---| +| R01, R03 | High | Clean hard-restart/live same-turn recovery and native contract tests | Recovery needs the retained SQLite directory and recoverable remote workspace/thread | +| R02 | High | Owner-scoped API/SQL tests and credential-safe event projection | Local-mode identity headers assume a trusted loopback/dev deployment; production uses existing authentication | +| R04 | High | Durable intent, race tests and native interruption | A disconnected environment cannot confirm stop immediately; UI says confirmation is pending | +| R05 | High | Remote receipt/lock and deterministic version reconciliation | Native correlation IDs are not remote idempotency keys; arbitrary agent side effects cannot be promised exactly once | +| R06, R07 | High | Backend/client projection tests, hook/DOM tests and live replay | Refresh after configured terminal retention expires does not restore deleted short-term history | +| R08 | Medium | Admission/TTL/lease/finish race tests | No production-scale load benchmark; one Studio instance only | +| R09 | Medium | Archive verification, SDK renewal contract, real x64 DevEnv | No full image rebuild, arm64 execution, or induced live TTL renewal | +| R10 | Medium | Affected suites, build/assets and comparison-base static checks | Existing Python type diagnostics and upstream build size/deprecation warnings remain | + +No impact severity is reduced merely because tests pass. Definite integrity or environment-loss failures remain terminal; recoverable and uncertain failures preserve output and offer continuation. SQLite fencing prevents stale local writes and does not claim globally exactly-once native effects. + +## Handoff + +Implementation and verification are complete within the scope above. R01–R10 are VERIFIED against the stated boundaries. Operational usage and configuration are documented in [README.md](README.md). The supported deployment is one Studio instance with a retained local SQLite directory and multiple isolated users. Existing Python type diagnostics remain unchanged. No High risk is blocked, and unexecuted environments are listed as residual uncertainty. No commit or push was made. + +Final evidence logs: `/tmp/studio-runs-full-impact-final.log`, `/tmp/studio-recoverable-frontend-tests-final6.log`, `/tmp/studio-recoverable-frontend-build-final5.log`, `/tmp/studio-recoverable-assets-final3.log`, `/tmp/studio-runs-pyright-final2.log`, `/tmp/studio-runs-pyright-comparison.json`, and `/tmp/studio-recoverable-browser/final-stop-browser3.log`. The packaged verifier checked 104 files and 248 internal references. The Markdown files are repository READMEs/report rather than MDX site pages; no site-wide Next.js build was necessary. diff --git a/frontend/scripts/checkDevelopmentTurnUi.mjs b/frontend/scripts/checkDevelopmentTurnUi.mjs new file mode 100644 index 000000000..5570e0235 --- /dev/null +++ b/frontend/scripts/checkDevelopmentTurnUi.mjs @@ -0,0 +1,364 @@ +// Run against a built Studio test server. Sandbox/task HTTP responses are controlled below. +const {chromium} = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core'); +import {mkdir, writeFile} from 'node:fs/promises'; +import assert from 'node:assert/strict'; +const out=process.env.EVIDENCE_DIR || '/tmp/studio-turn-ui-evidence'; +await mkdir(out,{recursive:true}); +const browser=await chromium.launch({executablePath:process.env.CHROMIUM_EXECUTABLE,headless:true,args:['--no-sandbox','--no-proxy-server']}); +const page=await browser.newPage({viewport:{width:1440,height:960},colorScheme:'dark'}); +const errors=[], requests=[], events=[]; +const checkPreparation=Boolean(process.env.CHECK_PREPARATION); +const checkHome=Boolean(process.env.CHECK_HOME); +const checkMigration=checkPreparation&&Boolean(process.env.CHECK_MIGRATION); +const english=(checkPreparation||checkHome)&&process.env.CHECK_LOCALE==='en-US'; +const label=(zh,en)=>english?en:zh; +let holdPreparation='create', heldPreparation=null; +let run=null, nextSeq=1, startedAt=0, firstSubscription=0, breakConnection=0, sourceAttempts=0; +const session={sessionId:'ux-session',status:'Ready',toolName:'intelligent-development',displayName:'构建体验验收',isMine:true,region:'cn-beijing',workspaceLocked:true,busy:false,model:'doubao-seed-2-1-pro-260628'}; +const delivery={sessionId:session.sessionId,agentName:'customer_analysis_assistant_with_a_long_project_name',entryPoint:'src/customer_analysis/agents/project_with_very_long_nested_directory_names/assistant_with_a_long_filename_for_preview_verification.py',fileCount:2,artifactSize:20480,artifactSha256:'a'.repeat(64),validationReportSha256:'b'.repeat(64),validatedAt:'2026-09-16T04:00:00Z',gateSummary:['ruff','pytest'],verified:true,deployable:true,validationSummary:'检查通过'}; +const project={schemaVersion:'1',origin:'migration',projectId:'ux-project',name:'Preserved project',createdAt:delivery.validatedAt,updatedAt:delivery.validatedAt,latestVersionId:'ux-version',latestVersionCreatedAt:delivery.validatedAt,latestVersionVerified:true,latestAgentName:delivery.agentName,versionCount:1}; +const version={...delivery,schemaVersion:'1',producer:'migration',migrationFramework:'any',projectId:project.projectId,versionId:'ux-version',parentVersionId:null,sourceSessionId:session.sessionId,createdAt:delivery.validatedAt,intentSummary:'Preserve the selected base',acceptanceCriteria:['Preserve the selected base']}; +function emit(type,payload){events.push({type,payload:{...payload,seq:nextSeq++,runId:'ux-run'}});if(run)run.lastSeq=nextSeq-1;} +let homeTasks=[],homeError=false,homeConnectFailure=false,holdHomeConnect=false,releaseHomeConnect=null; +if(checkHome){ + run={runId:'ux-run',sessionId:session.sessionId,requestId:'saved-request',message:'帮我做一个小学生数学辅导 agent',state:'running',phase:'coding',threadId:'thread-ux',turnId:'turn-ux',lastSeq:0,inputRevision:1,createdAt:Date.now()/1000-1800,statusMessage:'正在处理请求',stopRequested:false}; + emit('run.input',{clientId:run.requestId,message:run.message,status:'delivered'}); + emit('delta',{id:'saved-output',turnId:'turn-ux',text:'Persisted output before the page closed',snapshot:true}); + homeTasks=[run,{...run,runId:'ux-run-2',sessionId:'another-session',state:'waiting_user',createdAt:run.createdAt-60,message:'整理学习计划和练习题,并根据学生的答题情况提供反馈。'.repeat(8)},{...run,runId:'ux-run-3',sessionId:'recovering-session',state:'recovering',createdAt:run.createdAt-120,message:'客户服务知识库助手'}]; +} +page.on('pageerror',e=>errors.push(e.message)); +await page.addInitScript(locale=>{localStorage.setItem('veadk_local_user','studio-ux-verification');sessionStorage.setItem('veadk_local_user_tab','studio-ux-verification');localStorage.setItem('agentkit.studio.locale',locale);},english?'en-US':'zh-CN'); +if(checkPreparation||checkHome) await page.addInitScript(()=>{ + const originalFetch=window.fetch.bind(window); + window.fetch=(input,init)=>{ + const path=new URL(typeof input==='string'?input:input.url,location.origin).pathname; + // Exercise late responses even when the transport cannot cancel an in-flight request. + return originalFetch(input,path.startsWith('/web/intelligent-development/')&&init?.method==='POST'?{...init,signal:undefined}:init); + }; +}); +await page.route('**/web/model-options?*',r=>r.fulfill({json:{models:[session.model,...(checkPreparation?['alternate-development-model']:[])].map(id=>({id,displayName:id,available:true,lifecycleStatus:'Active'}))}})); +await page.route('**/web/intelligent-development/**',async route=>{ + const req=route.request(),u=new URL(req.url()),path=u.pathname,method=req.method();requests.push({path,method,...(checkPreparation&&method==='POST'?{body:req.postDataJSON()}: {})}); + const json=v=>route.fulfill({json:v}); + if(path.endsWith('/capabilities'))return json({enabled:true,reason:'',model:{configured:true,id:session.model},projectStorageEnabled:true}); + if(path.endsWith('/projects'))return json({projects:checkMigration?[project]:[]}); + if(checkMigration&&path.endsWith('/versions'))return json({versions:[version]}); + if(path.endsWith('/releases/summary')){sourceAttempts++;await new Promise(r=>setTimeout(r,400));if(sourceAttempts===1)return route.fulfill({status:503,json:{detail:'预览暂时不可用,请重试'}});return json({...delivery,files:[{path:delivery.entryPoint,content:'print("preview verified")'},{path:'README.md',content:'# Preview project'}]});} + if(path.endsWith('/sessions')&&method==='POST'){ + if(checkPreparation&&holdPreparation==='create')return new Promise(resolve=>{heldPreparation=async fail=>{await route.fulfill(fail?{status:503,json:{detail:'Late preparation failure'}}:{json:session});resolve();};}); + await new Promise(r=>setTimeout(r,900));return json(session); + } + if(path.endsWith('/connect')){ + if(checkHome&&homeConnectFailure)return route.fulfill({status:503,json:{detail:'Connection unavailable, please retry'}}); + if(checkHome&&holdHomeConnect)return new Promise(resolve=>{releaseHomeConnect=async()=>{await json(session);resolve();};}); + if(checkPreparation&&holdPreparation==='connect')return new Promise(resolve=>{heldPreparation=async fail=>{await route.fulfill(fail?{status:503,json:{detail:'Late connection failure'}}:{json:session});resolve();};}); + await new Promise(r=>setTimeout(r,700));return json(session); + } + if(path.endsWith('/sessions'))return json({sessions:run?[session]:[]}); + if(path.endsWith('/sessions/ux-session/runs')&&method==='POST'){ + const body=req.postDataJSON();await new Promise(r=>setTimeout(r,500)); + run={runId:'ux-run',sessionId:'ux-session',requestId:body.requestId,message:body.message,state:'running',phase:'coding',threadId:'thread-ux',turnId:'turn-ux',lastSeq:0,inputRevision:1,createdAt:Date.now()/1000,statusMessage:'正在处理请求',stopRequested:false}; + emit('run.input',{clientId:body.requestId,message:body.message,status:'delivered'}); + emit('activity',{id:'r1',turnId:'turn-ux',itemType:'reasoning',kind:'thinking',status:'running',text:''}); + startedAt=Date.now();return json(run); + } + if(checkHome&&path==='/web/intelligent-development/runs')return homeError?route.fulfill({status:503,json:{detail:'Tasks temporarily unavailable'}}):json({runs:homeTasks}); + if(path.endsWith('/runs'))return json({runs:run?[run]:[]}); + if(path.endsWith('/events')){ + if(breakConnection>0){breakConnection--;return route.abort('failed');} + if(!firstSubscription)firstSubscription=Date.now(); + const after=Number(u.searchParams.get('after')||0); + return route.fulfill({contentType:'text/event-stream',body:events.filter(e=>e.payload.seq>after).map(e=>`event: ${e.type}\ndata: ${JSON.stringify(e.payload)}\n\n`).join('')+'event: done\ndata: {}\n\n'}); + } + if(path.endsWith('/inputs')){const body=req.postDataJSON();emit('run.input',{clientId:body.clientId,message:body.message,status:'delivered'});return json({accepted:true});} + if(path.endsWith('/stop')){emit('development.succeeded',{turnId:'turn-ux',payload:{delivery}});emit('run.turn',{turnId:'turn-ux',status:'interrupted',durationMs:1542277,model:session.model,usageIncomplete:true,usage:{totalTokens:8000,inputTokens:6000,cachedInputTokens:4000,cacheWriteInputTokens:100,outputTokens:2000,reasoningOutputTokens:500}});run={...run,state:'cancelled',phase:'cancelled',statusMessage:'任务已停止',stopRequested:true};emit('run.status',run);return json(run);} + if(path.endsWith('/runs/ux-run'))return json(run); + if(checkHome&&/\/runs\/ux-run-[23]$/.test(path))return json({...run,runId:path.split('/').at(-1),state:'succeeded'}); + if(path.endsWith('/sessions/ux-session'))return json(session); + return json({}); +}); +try{ + await page.goto(process.env.STUDIO_TEST_URL || 'http://127.0.0.1:18080',{waitUntil:'networkidle'}); + await page.getByRole('button',{name:label('智能体','Agents'),exact:true}).click(); + await page.getByText(label('创建智能体','Create Agent'),{exact:true}).click(); + await page.getByRole('button',{name:english?/Advanced mode/:/传统模式/}).click(); + if(checkMigration){ + await page.getByText(label('从存量迁移','Migrate an existing project'),{exact:true}).click(); + await page.getByRole('button',{name:label('已迁移项目','Migrated projects'),exact:true}).click(); + await page.locator('.ic-project-disclosure').click(); + await page.getByRole('button',{name:label('去优化','Optimize'),exact:true}).click(); + }else await page.getByText(label('智能模式','Intelligent mode'),{exact:true}).click(); + if(checkHome){ + const tasks=page.locator('.ic-tasks-panel'); + const refresh=tasks.getByRole('button',{name:label('刷新任务列表','Refresh tasks'),exact:true}); + const enterHome=async()=>{ + await page.getByRole('button',{name:label('智能体','Agents'),exact:true}).click(); + await page.getByText(label('创建智能体','Create Agent'),{exact:true}).click(); + await page.getByRole('button',{name:english?/Advanced mode/:/传统模式/}).click(); + await page.getByText(label('智能模式','Intelligent mode'),{exact:true}).click(); + }; + await tasks.getByText(run.message,{exact:true}).waitFor(); + assert.equal(await page.locator('.ic-task').count(),3); + const layouts=[]; + for(const width of [1440,1100,600]){ + await page.setViewportSize({width,height:960}); + await page.locator('.ic-main').evaluate(el=>el.scrollTop=0); + const goalBox=await page.locator('.ic-goal-panel').boundingBox(),taskBox=await tasks.boundingBox(); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'No viewport overflow'); + if(width===1440){assert.ok(taskBox.x>goalBox.x+goalBox.width,'Desktop shows two distinct columns');assert.ok(Math.abs(taskBox.y-goalBox.y)<1);assert.ok(Math.abs(taskBox.height-goalBox.height)<1);} + else assert.ok(taskBox.yel.scrollTop=0); + const newGoal='保留首页正在编辑的需求'; + await page.locator('#intelligent-goal').fill(newGoal); + homeError=true;await refresh.click(); + await tasks.getByRole('alert').waitFor(); + assert.equal(await page.locator('.ic-task').count(),3,'Refresh failure preserves entries'); + assert.equal(await page.locator('#intelligent-goal').inputValue(),newGoal,'Polling does not reset the form'); + await page.screenshot({path:out+'/home-error.png'}); + homeError=false;await tasks.getByRole('button',{name:label('重试','Retry'),exact:true}).click(); + await tasks.getByRole('alert').waitFor({state:'hidden'}); + const savedTasks=homeTasks;homeTasks=[];await refresh.click(); + await tasks.getByText(label('暂无进行中的任务','No tasks in progress'),{exact:true}).waitFor(); + await tasks.getByRole('alert').waitFor({state:'hidden'}); + await page.screenshot({path:out+'/home-empty.png'}); + homeTasks=savedTasks;await refresh.click();await tasks.getByText(run.message,{exact:true}).waitFor(); + // Recreate the App with no task/session history in browser storage. + await page.evaluate(()=>{localStorage.clear();sessionStorage.clear();}); + await page.setViewportSize({width:600,height:960}); + await page.reload({waitUntil:'networkidle'});await enterHome(); + await tasks.getByText(run.message,{exact:true}).waitFor(); + assert.equal(await page.locator('.ic-main').evaluate(el=>el.scrollTop),0,'Autofocus must not scroll the recovery entry out of view'); + await page.setViewportSize({width:1440,height:960}); + homeConnectFailure=true; + await page.locator('.ic-task').filter({hasText:run.message}).click(); + await tasks.getByRole('alert').waitFor(); + assert.equal(await page.locator('.ic-task').count(),3,'Open error preserves the task entry'); + homeConnectFailure=false;holdHomeConnect=true; + await page.locator('.ic-task').filter({hasText:run.message}).click(); + await tasks.getByText(label('正在连接…','Connecting…'),{exact:true}).waitFor(); + await page.locator('button.new-chat--conversation').click(); + const deadline=Date.now()+5000;while(!releaseHomeConnect&&Date.now()r.method==='POST'&&(r.path.endsWith('/runs')||r.path.endsWith('/sessions'))).length,0,'Recovery never starts another build'); + await page.screenshot({path:out+'/home-restored.png'}); + await writeFile(out+'/home.json',JSON.stringify({locale:english?'en-US':'zh-CN',layouts,restoredOriginalOutput:true,noNewTask:true,errors},null,2)); + assert.deepEqual(errors,[]);console.log('Intelligent home recovery journey passed'); + await browser.close();process.exit(0); + } + await page.locator('textarea').fill('构建体验验收:保留输出并展示工具过程'); + if(checkPreparation){ + const goal='构建体验验收:保留输出并展示工具过程'; + const evidence=[]; + const buildLabel=checkMigration?label('开始优化','Start optimizing'):label('开始构建','Start building'); + await page.locator('.ic-model-select button').click(); + await page.getByRole('option',{name:/alternate-development-model/}).click(); + for(const [stage,fail,width] of [['create',false,1440],['connect',false,600],['connect',true,1440]]){ + holdPreparation=stage;heldPreparation=null; + await page.setViewportSize({width,height:960}); + await page.getByRole('button',{name:buildLabel,exact:true}).click(); + await page.waitForFunction(()=>Boolean(document.querySelector('.development-preparation'))); + const deadline=Date.now()+5000; + while(!heldPreparation&&Date.now()el===document.activeElement),true,'Return keyboard focus to the form'); + if(checkMigration) assert.match(await page.locator('.ic-selected-base').innerText(),/Preserved project/); + const release=heldPreparation;await release(fail); + await page.waitForTimeout(250); + assert.equal(await page.locator('#intelligent-goal').inputValue(),goal); + assert.equal(await page.locator('.sandbox-codex-composer').count(),0); + assert.equal(await page.getByText(/Late (preparation|connection) failure/).count(),0); + assert.equal(requests.filter(r=>r.path.endsWith('/runs')&&r.method==='POST').length,0); + evidence.push({stage,lateFailure:fail,width,draftPreserved:true,modelPreserved:true,noRunStarted:true,cancelBox}); + } + assert.equal(evidence[0].cancelBox.x,evidence[2].cancelBox.x,'Stage changes preserve the cancel position'); + // A current failure remains visible and retry uses the retained draft/model. + holdPreparation='create';heldPreparation=null; + await page.getByRole('button',{name:buildLabel,exact:true}).click(); + const deadline=Date.now()+5000; + while(!heldPreparation&&Date.now()r.path.endsWith('/runs')&&r.method==='POST').length,1); + const creations=requests.filter(r=>r.path.endsWith('/sessions')&&r.method==='POST'); + assert.ok(creations.every(r=>r.body.modelId==='alternate-development-model')); + if(checkMigration) assert.ok(creations.every(r=>r.body.projectId===project.projectId&&r.body.baseVersionId===version.versionId)); + await page.getByRole('button',{name:label('停止生成','Stop generating'),exact:true}).waitFor(); + await page.screenshot({path:out+'/after-retry.png'}); + await writeFile(out+'/preparation-cancel.json',JSON.stringify({locale:english?'en-US':'zh-CN',migration:checkMigration,evidence,errors},null,2)); + assert.deepEqual(errors,[]); + console.log('Preparation cancellation journey passed'); + await browser.close();process.exit(0); + } + + await page.screenshot({path:out+'/before-start.png'}); + await page.locator('textarea').press('Control+Enter'); + if(await page.locator('.development-preparation').count()===0){ + const buttons=page.getByRole('button',{name:/开始构建|开始创建|构建 Agent/}); + if(await buttons.count())await buttons.first().click(); + } + await page.waitForSelector('.development-preparation',{timeout:5000}); + await page.screenshot({path:out+'/preparation.png'}); + const samples=[]; + for(let i=0;i<28;i++){ + samples.push(await page.evaluate(()=>({welcome:!!document.querySelector('.welcome'),goal:document.body.innerText.includes('构建体验验收:保留输出并展示工具过程'),preparation:!!document.querySelector('.development-preparation')}))); + await page.waitForTimeout(100); + } + assert.ok(samples.every(s=>!s.welcome&&s.goal),'task startup must never flash a welcome or clear the request'); + await page.getByRole('button',{name:'正在思考',exact:true}).waitFor(); + await page.screenshot({path:out+'/initial-thinking.png'}); + if(process.env.CHECK_DELIVERY_STAGES){ + emit('activity',{id:'r1',turnId:'turn-ux',itemType:'reasoning',kind:'thinking',status:'done',text:'检查完成'}); + emit('delta',{id:'answer',turnId:'turn-ux',text:'数学辅导智能体已完成开发和验证。',snapshot:true}); + emit('run.turn',{turnId:'turn-ux',status:'completed',durationMs:1234,model:session.model,usage:{totalTokens:120,inputTokens:100,outputTokens:20}}); + for(const [phase,label] of [['outcome_read','正在整理产物'],['delivery','正在整理产物'],['version','正在保存版本'],['reporting','正在补齐交付信息']]){ + run={...run,phase,statusMessage:label};emit('run.status',run); + await page.getByText(label,{exact:true}).last().waitFor(); + assert.equal(await page.locator('.delivery-card').count(),0,'native turn completion must not publish an unfinished artifact'); + assert.equal(await page.locator('.development-turn-summary').count(),1,'delivery stages must not invent native turns'); + assert.match(await page.locator('.development-turn-summary').innerText(),/1\.2 秒/); + assert.ok(await page.getByRole('button',{name:'停止生成',exact:true}).isEnabled()); + await page.screenshot({path:out+'/stage-'+phase+'.png'}); + } + emit('development.succeeded',{turnId:'turn-ux',payload:{delivery}}); + run={...run,state:'succeeded',phase:'complete',statusMessage:''};emit('run.status',run); + await page.locator('.delivery-card').waitFor(); + assert.equal(await page.locator('.delivery-card').count(),1); + assert.equal(await page.locator('.development-turn-summary').count(),1); + await page.getByRole('button',{name:'停止生成',exact:true}).waitFor({state:'hidden'}); + assert.deepEqual(errors,[]); + await page.screenshot({path:out+'/artifact-ready.png'}); + await writeFile(out+'/delivery-stages.json',JSON.stringify({stages:4,nativeTurns:1,artifactCards:1,stopAvailableDuringDelivery:true,errors},null,2)); + } else { + const startupGeometry=await page.locator('.turn--user .bubble').first().boundingBox(); + assert.ok(firstSubscription-startedAt<500,`subscription delayed ${firstSubscription-startedAt}ms`); + const group=page.locator('.development-process__toggle').first(); + assert.equal(await group.getAttribute('aria-expanded'),'false'); + await group.focus();await page.keyboard.press('Enter'); + assert.equal(await group.getAttribute('aria-expanded'),'true'); + emit('activity',{id:'r1',turnId:'turn-ux',itemType:'reasoning',kind:'thinking',status:'done',text:'检查项目结构与验收标准'}); + emit('activity',{id:'c1',turnId:'turn-ux',itemType:'commandExecution',kind:'tool',name:'运行命令',status:'running',args:{command:'python -m pytest --verbose tests/test_a_really_long_command_that_should_never_wrap_the_header.py',commandActions:[{type:'unknown'}]}}); + emit('tool_output',{id:'c1',turnId:'turn-ux',text:'LIVE_LOG_START\n'+Array.from({length:150},(_,i)=>`line ${i} verified`).join('\n')+'\nLIVE_LOG_END'}); + await page.waitForFunction(()=>document.querySelector('.development-process__toggle')?.textContent.includes('执行命令')); + assert.equal(await group.getAttribute('aria-expanded'),'true'); + const alignment=await page.evaluate(()=>{ + const think=document.querySelector('.think-label').getBoundingClientRect(); + const tool=document.querySelector('.tool-name').getBoundingClientRect(); + return {thinkingX:think.x,toolX:tool.x,toolHeight:tool.height,label:document.querySelector('.tool-name').textContent}; + }); + assert.ok(Math.abs(alignment.thinkingX-alignment.toolX)<1,JSON.stringify(alignment)); + assert.ok(alignment.toolHeight<22); + assert.equal(alignment.label,'执行命令 · Run tests'); + await page.locator('.tool-head').first().click(); + await page.getByText(/LIVE_LOG_END/).first().waitFor(); + emit('activity',{id:'c1',turnId:'turn-ux',itemType:'commandExecution',kind:'tool',name:'运行命令',status:'error',durationMs:432,response:{exitCode:1,output:'file missing'},args:{command:'python -m pytest --verbose tests/test_a_really_long_command_that_should_never_wrap_the_header.py',commandActions:[{type:'unknown'}]}}); + emit('plan',{turnId:'turn-ux',text:'修复并验证',plan:[{step:'检查项目',status:'completed'},{step:'修复文件',status:'inProgress'}]}); + emit('diff',{turnId:'turn-ux',text:'diff --git a/agent.py b/agent.py\n--- a/agent.py\n+++ b/agent.py\n+fixed = True'}); + emit('delta',{id:'a1',turnId:'turn-ux',itemType:'agentMessage',phase:'commentary',text:'已保留已有输出,正在继续验证。'}); + emit('activity',{id:'m1',turnId:'turn-ux',itemType:'mcpToolCall',kind:'tool',name:'MCP · docs/search',status:'running',args:{query:'validation'}}); + emit('tool_progress',{id:'m1',turnId:'turn-ux',text:'已检索到 3 条参考资料'}); + await page.getByText('已保留已有输出,正在继续验证。',{exact:true}).waitFor(); + assert.equal(await page.locator('.development-process').count(),2); + assert.doesNotMatch((await page.locator('.tool-head').allInnerTexts()).join('\n'),/毫秒|\bms\b/); + assert.match(await page.locator('.development-process__duration').first().innerText(),/432 毫秒/); + assert.ok(await page.locator('.development-diff summary svg').count()>0); + assert.ok(await page.locator('.plan-icon svg').count()>0); + await page.locator('.development-diff summary').click(); + await page.screenshot({path:out+'/expanded-desktop.png'}); + await group.click(); + assert.equal(await page.locator('.development-process__failure').count(),0); + breakConnection=3; + await page.waitForFunction(()=>/连接|重连/.test(document.querySelectorAll('.development-process__toggle')[1]?.textContent||''),{timeout:10000}); + assert.ok(await page.getByText('已保留已有输出,正在继续验证。',{exact:true}).isVisible()); + const input=page.locator('textarea');await input.fill('补充要求:保留原有文件'); + await input.dispatchEvent('keydown',{key:'Enter',isComposing:true,bubbles:true});assert.equal(await input.inputValue(),'补充要求:保留原有文件'); + const steerRequest=page.waitForRequest(r=>r.url().endsWith('/inputs')); + await input.press('Enter');await steerRequest; + await page.waitForFunction(()=>document.querySelectorAll('.turn--user').length===2); + assert.equal(requests.filter(r=>r.path.endsWith('/inputs')).length,1); + breakConnection=0; + await page.getByText('正在处理请求',{exact:true}).last().waitFor(); + await page.screenshot({path:out+'/steer-status.png'}); + await page.getByRole('button',{name:'智能体',exact:true}).click(); + await page.getByRole('button',{name:'返回任务',exact:true}).waitFor(); + await page.getByRole('button',{name:'返回任务',exact:true}).click(); + await page.getByText('正在处理请求',{exact:true}).last().waitFor(); + await page.getByRole('button',{name:'返回任务',exact:true}).waitFor({state:'hidden'}); + emit('activity',{id:'m1',turnId:'turn-ux',itemType:'mcpToolCall',kind:'tool',name:'MCP · docs/search',status:'done',durationMs:0}); + await page.getByRole('button',{name:'停止生成',exact:true}).click(); + await page.waitForFunction(()=>!document.querySelector('.sandbox-codex-composer button[aria-label="停止生成"]')); + assert.ok(await page.getByText('已保留已有输出,正在继续验证。',{exact:true}).isVisible()); + await page.locator('.development-turn-summary').waitFor(); + const summary=page.locator('.development-turn-summary'); + assert.match(await summary.innerText(),/2 次工具调用/); + assert.equal(await page.locator('.turn-empty').count(),0); + assert.match(await summary.innerText(),/25 分 42\.3 秒/); + assert.match(await summary.innerText(),/432 毫秒/); + assert.match(await page.locator('.development-process__duration').last().innerText(),/<1 毫秒/); + const tokens=summary.getByRole('button'); + assert.equal(await tokens.evaluate(el=>getComputedStyle(el).borderTopWidth),'0px'); + await tokens.hover(); + await page.locator('.development-token-popup').waitFor(); + let detail=await page.locator('.development-token-popup').innerText(); + assert.ok(detail.includes(session.model)); + assert.match(detail,/未命中输入\s+2,000/); + assert.match(detail,/66.7%/); + await page.screenshot({path:out+'/token-details-desktop.png'}); + await page.keyboard.press('Escape'); + await page.locator('.development-token-popup').waitFor({state:'hidden'}); + await page.mouse.move(0,0); + await tokens.focus(); + await page.locator('.development-token-popup').waitFor(); + await page.keyboard.press('Escape'); + await page.locator('.development-token-popup').waitFor({state:'hidden'}); + await page.keyboard.press('Enter'); + await page.locator('.development-token-popup').waitFor(); + await page.keyboard.press('Escape'); + const card=page.locator('.delivery-card');await card.waitFor(); + assert.equal(await card.locator('strong').innerText(),delivery.agentName); + assert.equal(await card.locator('.delivery-card-entry dd').innerText(),delivery.entryPoint); + await card.scrollIntoViewIfNeeded();await page.screenshot({path:out+'/delivery-desktop.png'}); + const sourceButton=card.getByRole('button',{name:'查看源码',exact:true}); + await sourceButton.click();assert.equal(await sourceButton.isDisabled(),true); + await card.getByRole('alert').waitFor();assert.equal(await sourceButton.isEnabled(),true); + await sourceButton.click();await page.locator('.code-browser-dialog').waitFor(); + await page.keyboard.press('Escape');await page.locator('.code-browser-dialog').waitFor({state:'hidden'}); + await page.setViewportSize({width:820,height:900});await page.emulateMedia({reducedMotion:'reduce'}); + await tokens.hover();await page.locator('.development-token-popup').waitFor();await page.screenshot({path:out+'/stopped-narrow.png'}); + const layout=await page.evaluate(()=>({width:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth}));assert.ok(layout.scrollWidth<=layout.width); + await page.keyboard.press('Escape');await page.setViewportSize({width:600,height:900});await card.scrollIntoViewIfNeeded(); + const cardLayout=await card.evaluate(el=>({width:el.clientWidth,scrollWidth:el.scrollWidth,entryHeight:el.querySelector('.delivery-card-entry dd').getBoundingClientRect().height})); + assert.ok(cardLayout.scrollWidth<=cardLayout.width);assert.ok(cardLayout.entryHeight>18); + await page.screenshot({path:out+'/delivery-narrow.png'}); + assert.deepEqual(errors,[]); + await writeFile(out+'/browser.json',JSON.stringify({alignment,turnSummary:await summary.innerText(),tokenDetails:detail,startupGeometry,startupSamples:samples,subscriptionDelayMs:firstSubscription-startedAt,keyboardDisclosure:true,expansionPreserved:true,toolLogEndVisible:true,planAndDiff:true,reconnectPreservesOutput:true,steer:true,backgroundNoticeReturn:true,stop:true,ime:true,layout,pageErrors:errors,apiBoundary:'controlled Codex event and Sandbox API responses; actual built App, HTTP client, hook, projection and UI'},null,2)); + console.log('Browser journey passed'); + } +}catch(e){await page.screenshot({path:out+'/browser-failure.png'});console.log('failure body', (await page.locator('body').innerText()).slice(-4000));throw e;} +finally{await browser.close();} diff --git a/frontend/server/intelligent_development_projects/service.py b/frontend/server/intelligent_development_projects/service.py index 3f9ebd3ac..6a581ae02 100644 --- a/frontend/server/intelligent_development_projects/service.py +++ b/frontend/server/intelligent_development_projects/service.py @@ -40,14 +40,15 @@ IntelligentDevelopmentVersion, SourceProjectOrigin, SourceVersionEnvironment, + SourceVersionView, StoredDevelopmentVersion, ) from .repository import ( IntelligentDevelopmentProjectNotFound, IntelligentDevelopmentProjectStorageUnavailable, + IntelligentDevelopmentVersionNotFound, TosIntelligentDevelopmentProjectRepository, ) -from .models import SourceVersionView _MAX_ARTIFACT_BYTES = SOURCE_PROJECT_MAX_BYTES _MAX_REPORT_BYTES = SOURCE_PROJECT_MAX_REPORT_BYTES @@ -269,8 +270,32 @@ async def persist_delivery( transport: SandboxRemoteTransport, delivery: DeliveryReference, decision: IntentDecision, + version_id: str | None = None, + created_at: datetime | None = None, ) -> tuple[IntelligentDevelopmentProject, IntelligentDevelopmentVersion]: binding = await self._resolved_binding(owner_id, session_id) + if version_id is not None: + try: + existing = await self.repository.get_version( + owner_id, binding.project_id, version_id + ) + except IntelligentDevelopmentVersionNotFound: + pass + else: + if ( + existing.artifact_sha256 != delivery.artifact_sha256 + or existing.validation_report_sha256 + != delivery.validation_report_sha256 + ): + raise ValueError( + "A task version cannot be reused for different artifacts" + ) + project = await self.repository.get_project( + owner_id, binding.project_id + ) + # _resolved_binding repairs the newest committed version. A + # delayed retry of this task must never rewind that association. + return project, existing base_version = ( await self.repository.get_version( owner_id, @@ -329,10 +354,10 @@ async def persist_delivery( if isinstance(criteria, list) else list(decision.acceptance_criteria) ) - now = datetime.now(timezone.utc) + now = created_at or datetime.now(timezone.utc) version = IntelligentDevelopmentVersion( projectId=binding.project_id, - versionId=uuid4().hex, + versionId=version_id or uuid4().hex, parentVersionId=binding.base_version_id, sourceSessionId=session_id, createdAt=now, diff --git a/frontend/server/intelligent_development_routes.py b/frontend/server/intelligent_development_routes.py index 278453dd0..965cdbf68 100644 --- a/frontend/server/intelligent_development_routes.py +++ b/frontend/server/intelligent_development_routes.py @@ -12,19 +12,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Studio-only HTTP surface for foreground intelligent development.""" +"""Studio HTTP surface for recoverable intelligent development.""" from __future__ import annotations import asyncio import contextlib +import hashlib import json +import os import re import shlex -import time from collections.abc import AsyncGenerator, Callable from dataclasses import replace from datetime import datetime, timezone +from pathlib import Path from typing import Any from uuid import uuid4 @@ -34,18 +36,6 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse from starlette.types import Receive, Scope, Send -from frontend.server.intelligent_development_task import ( - COMPLETION_FILE_PREFIX, - CredentialResolver, - DeliveryPublisher, - IntentDecision, - builder_prompt, - create_credential_lease, - invalidate_current_delivery, - parse_intent_decision, - read_completion_contract, - remove_completion_file, -) from frontend.server.intelligent_development_projects import ( IntelligentDevelopmentProjectNotFound, IntelligentDevelopmentProjectService, @@ -53,9 +43,12 @@ from frontend.server.intelligent_development_projects.routes import ( PROJECT_EXCEPTIONS, mount_intelligent_development_project_routes, - project_error_detail, project_http_error, ) +from frontend.server.intelligent_development_task import ( + CredentialResolver, + parse_intent_decision, +) from frontend.server.sandbox_remote import SandboxRemoteTransport from veadk.cli.codex_app_server import ( CodexAppServerError, @@ -236,6 +229,9 @@ async def list_snapshots(self, *args: Any, **kwargs: Any) -> Any: async def get_session(self, *args: Any, **kwargs: Any) -> Any: return await self._gateway.get_session(*args, **kwargs) + async def renew_session(self, session: SandboxCloudSession) -> SandboxCloudSession: + return await self._gateway.renew_session(session) + async def delete_session(self, *args: Any, **kwargs: Any) -> None: await self._gateway.delete_session(*args, **kwargs) @@ -598,7 +594,7 @@ async def resolve_intelligent_development_session( owner_id: str, ) -> SandboxCloudSession: """Resolve an owner-scoped Dev Session without granting admin bypass.""" - cloud = await service._cloud_session(session_id) + cloud = await service.owned_cloud_session(session_id, owner_id) if cloud.created_by != owner_id: raise SandboxSessionNotFoundError("智能开发 Session 不存在或不属于当前用户。") _require_development_session(cloud) @@ -719,6 +715,9 @@ def public(value: object) -> object: if event.kind in {"commentary", "thinking"}: payload = { "id": event.item_id, + "itemType": event.item_type, + "phase": event.phase, + "durationMs": event.duration_ms, "kind": event.kind, "status": event.status, "text": public(event.text), @@ -730,6 +729,9 @@ def public(value: object) -> object: if event.kind == "tool": payload = { "id": event.item_id, + "itemType": event.item_type, + "phase": event.phase, + "durationMs": event.duration_ms, "kind": "tool", "status": event.status, "text": None, @@ -738,6 +740,21 @@ def public(value: object) -> object: "response": public(event.response), } return f"event: activity\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + if event.kind in { + "tool_output", + "tool_output_snapshot", + "tool_progress", + "plan", + "diff", + }: + payload = { + "id": event.item_id, + "text": public(event.text), + "plan": public(event.response), + "status": event.status, + "snapshot": event.kind == "tool_output_snapshot", + } + return f"event: {event.kind.removesuffix('_snapshot')}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" if event.kind == "usage" and event.usage is not None: payload = { "turnId": event.turn_id, @@ -894,6 +911,7 @@ def mount_intelligent_development_routes( configured: bool = True, validation_region: str = "cn-beijing", validation_project: str = "default", + run_database: str | Path | None = None, ) -> None: """Mount the Codex-gated SANDBOX_DEV surface.""" @@ -907,13 +925,129 @@ def mount_intelligent_development_routes( ) delegated = FastAPI() - task_locks: dict[tuple[str, str], asyncio.Lock] = {} - task_locks_guard = asyncio.Lock() + from frontend.server.intelligent_development_runs.repository import ( + RunConflict, + RunRepository, + ) + from frontend.server.intelligent_development_runs.routes import mount_run_routes + from frontend.server.intelligent_development_runs.runner import DevelopmentRunner + from frontend.server.intelligent_development_runs.service import RunService + from veadk.cli.frontend_sandbox import public_codex_event, redact_sandbox_text + + repository = RunRepository( + run_database + or os.getenv("VEADK_STUDIO_TASK_DB") + or Path.home() / ".veadk" / "studio" / "development-runs.sqlite3", + retention_seconds=float( + os.getenv("VEADK_STUDIO_TASK_RETENTION_SECONDS", "21600") + ), + max_active_seconds=float( + os.getenv("VEADK_STUDIO_TASK_MAX_ACTIVE_SECONDS", "28800") + ), + ) + + def credentials(): + if credential_resolver is None: + raise SandboxConfigurationError("智能开发云端凭据尚未配置。") + return credential_resolver() + + def redact_message(message: str) -> str: + return str( + _redact_task_event_value( + redact_sandbox_text(message), + exact_secrets=credentials().secret_values, + task_paths=(), + launcher_path="", + ) + ) + + def render_event(event, lease): + if event.kind in {"text", "text_snapshot", "assistant_final"}: + return "delta", { + "id": event.item_id, + "text": str( + _redact_task_event_value( + redact_sandbox_text(event.text), + exact_secrets=lease.exact_secrets, + task_paths=(lease.root,), + launcher_path=lease.launcher_path, + ) + ), + "snapshot": event.kind != "text", + "itemType": event.item_type, + "phase": event.phase, + } + if event.kind == "recovery": + return "progress", {"text": "Codex 正在自动恢复,请稍候。"} + if event.kind == "thread_status": + status = event.response if isinstance(event.response, dict) else {} + flags = status.get("activeFlags", []) + message = ( + "等待审批" + if "waitingOnApproval" in flags + else "等待你的输入" + if "waitingOnUserInput" in flags + else "正在恢复连接" + if status.get("type") in {"systemError", "notLoaded"} + else "" + ) + return "progress", {"text": message} + frame = _conversation_event_sse( + public_codex_event(event), + exact_secrets=lease.exact_secrets, + task_paths=(lease.credential_path, lease.launcher_path, lease.root), + launcher_path=lease.launcher_path, + ) + if frame is None: + return None + name, data = frame.strip().split("\ndata: ", 1) + return name.removeprefix("event: "), json.loads(data) + + async def resolve(session_id: str, owner: str): + return await resolve_intelligent_development_session(service, session_id, owner) + + runner = DevelopmentRunner( + repository, + resolve_session=resolve, + workspace=_workspace, + credentials=credentials, + render_event=render_event, + command_progress=lambda event: _command_progress(public_codex_event(event)), + renew_session=service.renew_owned_session, + project_service=project_service, + validation_region=validation_region, + validation_project=validation_project, + ) + run_service = RunService(repository, runner) + app.state.intelligent_development_runs = run_service async def task_active(owner_id: str, session_id: str) -> bool: - async with task_locks_guard: - task_lock = task_locks.get((owner_id, session_id)) - return task_lock is not None and task_lock.locked() + return any( + not run.terminal + for run in await repository.session_runs(owner_id, session_id) + ) + + async def prepare_run(session_id: str, owner: str) -> str: + try: + await resolve(session_id, owner) + previous = await repository.session_runs(owner, session_id) + if previous and previous[-1].thread_id: + return previous[-1].thread_id + # First submission uses the thread prepared by /connect. Subsequent + # observations and server restarts use the exact persisted association. + service.require_owned(session_id, owner) + return str(service.settings(session_id, owner)["threadId"]) + except SandboxError as error: + raise _http_error(error) from error + + mount_run_routes( + app, + prefix=INTELLIGENT_DEVELOPMENT_PREFIX, + service=run_service, + owner_resolver=owner_resolver, + prepare=prepare_run, + redact_message=redact_message, + ) mount_sandbox_routes( delegated, @@ -1112,6 +1246,17 @@ async def _connect(session_id: str, request: Request) -> dict[str, object]: service, session_id, owner ) workspace = _workspace(cloud) + runs = await repository.session_runs(owner, session_id) + if runs: + run = runs[-1] + return { + **_public_session(cloud, busy=not run.terminal), + "threadId": run.thread_id, + "cwd": workspace, + "workspaceLocked": True, + "permissions": _BUILDER_PERMISSIONS.public_dict(), + "run": run.public(), + } if await task_active(owner, session_id): conversation = service._owned(session_id, owner) settings = service.settings(session_id, owner) @@ -1167,6 +1312,15 @@ async def _connect(session_id: str, request: Request) -> dict[str, object]: @app.get(f"{INTELLIGENT_DEVELOPMENT_PREFIX}/sessions/{{session_id}}/status") async def _status(session_id: str, request: Request) -> dict[str, object]: owner = owner_resolver(request) + runs = await repository.session_runs(owner, session_id) + if runs: + run = runs[-1] + return { + "threadId": run.thread_id, + "busy": not run.terminal, + "permissions": _BUILDER_PERMISSIONS.public_dict(), + "run": run.public(), + } try: status = service.status(session_id, owner) except SandboxError as error: @@ -1324,481 +1478,75 @@ async def _release_download( ) @app.post(f"{INTELLIGENT_DEVELOPMENT_PREFIX}/sessions/{{session_id}}/interrupt") - async def _interrupt(session_id: str, request: Request) -> dict[str, bool]: + async def _interrupt(session_id: str, request: Request) -> dict[str, object]: owner = owner_resolver(request) - lock_key = (owner, session_id) - try: - await resolve_intelligent_development_session(service, session_id, owner) - conversation = service._owned(session_id, owner) - async with task_locks_guard: - task_lock = task_locks.get(lock_key) + runs = await repository.session_runs(owner, session_id) + active = next((run for run in reversed(runs) if not run.terminal), None) + if active is not None: logger.info( - "Intelligent development reason=interrupt_requested session_id=%s thread_id=%s", + "Development lifecycle run_id=%s session_id=%s thread_id=%s reason=interrupt_requested", + active.id, session_id, - conversation.codex.thread_id, + active.thread_id, ) - await conversation.codex.interrupt() - if task_lock is not None: - await task_lock.acquire() - task_lock.release() + run = await run_service.stop(owner, active.id) + return {"interrupted": False, "stopRequested": True, "run": run.public()} + try: + await resolve(session_id, owner) + await service.interrupt(session_id, owner) except SandboxError as error: raise _http_error(error) from error return {"interrupted": True} @app.post(f"{INTELLIGENT_DEVELOPMENT_PREFIX}/sessions/{{session_id}}/messages") async def _message(session_id: str, request: Request) -> StreamingResponse: + """Compatibility stream: disconnecting only detaches the subscriber.""" owner = owner_resolver(request) - project_context = "" - trusted_manifest_metadata: tuple[str, str] | None = None try: data = await _request_object(request, 128 * 1024) - if set(data) != {"message"}: - raise SandboxValidationError("智能开发会话只接受文本消息。") prompt = data.get("message") - if not isinstance(prompt, str) or not prompt.strip(): - raise SandboxValidationError("message must not be empty") - if len(prompt) > 100_000: - raise SandboxValidationError("message is too large") - service.require_owned(session_id, owner) - thread_id = service._owned(session_id, owner).codex.thread_id - cloud = await resolve_intelligent_development_session( - service, session_id, owner + if ( + set(data) != {"message"} + or not isinstance(prompt, str) + or not prompt.strip() + or len(prompt) > 100_000 + ): + raise SandboxValidationError("智能开发会话只接受非空文本消息。") + thread_id = await prepare_run(session_id, owner) + request_id = request.headers.get("Idempotency-Key") or uuid4().hex + run = await repository.create( + owner, + session_id, + request_id, + redact_message(prompt.strip()), + thread_id=thread_id, + message_digest=hashlib.sha256(prompt.strip().encode()).hexdigest(), ) - project_root = _workspace(cloud) - if project_service is not None: - try: - await project_service.get_binding(owner, session_id) - except IntelligentDevelopmentProjectNotFound: - await project_service.create_binding( - owner_id=owner, - session_id=session_id, - display_name=cloud.display_name, - ) - base = await project_service.base_metadata(owner, session_id) - if base is not None: - trusted_manifest_metadata = (base.agent_name, base.entry_point) - project_context = json.dumps( - { - "intentSummary": base.intent_summary, - "acceptanceCriteria": base.acceptance_criteria, - "agentName": base.agent_name, - "entryPoint": base.entry_point, - }, - ensure_ascii=False, - separators=(",", ":"), - ) except SandboxError as error: raise _http_error(error) from error - except PROJECT_EXCEPTIONS as error: - raise project_http_error(error) from error - - lock_key = (owner, session_id) - async with task_locks_guard: - task_lock = task_locks.setdefault(lock_key, asyncio.Lock()) - if task_lock.locked(): - raise _http_error( - IntelligentDevelopmentTaskInProgressError( - "当前智能开发任务仍在进行,请稍后继续。" - ) - ) - await task_lock.acquire() - - async def stream() -> AsyncGenerator[str, None]: - lease = None - completion_path = "" - emitted_progress: set[str] = set() - failure_stage = "task_prepare" - started_at = time.monotonic() - logger.info( - "Intelligent development stream started session_id=%s thread_id=%s", - session_id, - thread_id, - ) - - async def cleanup_task_files() -> None: - nonlocal completion_path, lease - completion_error: Exception | None = None - credential_error: Exception | None = None - if completion_path: - try: - await remove_completion_file( - SandboxRemoteTransport(cloud.endpoint), completion_path - ) - completion_path = "" - except Exception as error: # noqa: BLE001 - completion_error = error - logger.warning( - "Intelligent development reason=completion_cleanup_failed stage=%s session_id=%s thread_id=%s error_types=%s", - failure_stage, - session_id, - thread_id, - _error_types(error), - ) - if lease is not None: - try: - await lease.cleanup() - lease = None - except Exception as error: # noqa: BLE001 - credential_error = error - logger.warning( - "Intelligent development reason=credential_cleanup_failed stage=%s session_id=%s thread_id=%s error_types=%s", - failure_stage, - session_id, - thread_id, - _error_types(error), - ) - if credential_error is not None: - logger.warning( - "Intelligent development reason=session_delete_requested trigger=credential_cleanup_failure session_id=%s thread_id=%s", - session_id, - thread_id, - ) - try: - await service.delete(session_id, owner) - except Exception as error: # noqa: BLE001 - logger.error( - "Intelligent development reason=session_delete_failed trigger=credential_cleanup_failure session_id=%s thread_id=%s error_types=%s", - session_id, - thread_id, - _error_types(error), - ) - raise SandboxError( - "临时凭据清理未能确认,开发环境自动终止也失败。" - "请勿继续使用当前会话,并联系管理员。" - ) from credential_error - logger.warning( - "Intelligent development reason=session_delete_completed trigger=credential_cleanup_failure session_id=%s thread_id=%s", - session_id, - thread_id, - ) - completion_path = "" - lease = None - raise SandboxSessionNotFoundError( - "临时凭据清理未能确认;为保护凭据,开发环境已自动终止。" - "请新建会话后重试。" - ) from credential_error - if completion_error is not None: - raise IntelligentDevelopmentCleanupError( - "临时交付证据文件未能清理,本轮已停止交付。请重试。" - ) from completion_error - logger.info( - "Intelligent development reason=task_cleanup_completed stage=%s session_id=%s thread_id=%s", - failure_stage, - session_id, - thread_id, - ) - - try: - failure_stage = "task_prepare" - yield _progress_sse("Codex 正在处理本次请求。") - transport = SandboxRemoteTransport(cloud.endpoint) - completion_path = ( - f"{project_root}/{COMPLETION_FILE_PREFIX}{uuid4().hex}.json" - ) - if credential_resolver is None: - raise SandboxConfigurationError("智能开发云端凭据尚未配置。") - lease = await create_credential_lease( - cloud.endpoint, credential_resolver - ) - delivery = None - failure_stage = "codex_turn" - async for event in service.stream_message( - session_id, - owner, - builder_prompt( - prompt.strip(), - launcher_path=lease.launcher_path, - completion_path=completion_path, - expire_at=cloud.expire_at, - remaining_lifetime_minutes=_remaining_lifetime_minutes( - cloud.expire_at - ), - validation_region=validation_region, - validation_project=validation_project, - project_context=project_context, - ), - turn_permissions=_BUILDER_PERMISSIONS, - turn_timeout_seconds=_BUILDER_TURN_TIMEOUT_SECONDS, - turn_output_schema=None, - ): - progress = _command_progress(event) - if progress is not None and progress not in emitted_progress: - emitted_progress.add(progress) - yield _progress_sse(progress) - public_event = _conversation_event_sse( - event, - exact_secrets=lease.exact_secrets, - task_paths=( - lease.credential_path, - lease.launcher_path, - lease.root, - ), - launcher_path=lease.launcher_path, - ) - if public_event is not None: - yield public_event + except (RunConflict, ValueError) as error: + raise HTTPException( + 409, "当前任务仍在进行,请追加要求或先停止任务。" + ) from error + run_service.launch(run) - failure_stage = "outcome_read" - try: - completion = await read_completion_contract( - transport, completion_path - ) - except ValueError as error: - logger.warning( - "Intelligent development outcome was invalid for %s: %s", - session_id, - type(error).__name__, - ) - raise IntelligentDevelopmentOutcomeError( - "Codex 未返回有效的本轮结果。" - ) from error - except Exception as error: # noqa: BLE001 - logger.warning( - "Intelligent development outcome was unavailable for %s: %s", - session_id, - type(error).__name__, - ) - raise IntelligentDevelopmentOutcomeError( - "Codex 未返回本轮结果。" - ) from error - - if completion.answered: - failure_stage = "cleanup" - await cleanup_task_files() - failure_stage = "complete" - yield "event: done\ndata: {}\n\n" - return - if not completion.intent_summary or not completion.acceptance_criteria: - raise IntelligentDevelopmentOutcomeError( - "Codex 返回的交付上下文不完整。" - ) - decision = IntentDecision( - "accept", - "", - completion.intent_summary, - completion.acceptance_criteria, - True, - ) - await invalidate_current_delivery(transport) - if completion.verified: - yield _progress_sse( - "Agent 已完成实现、检查和临时云端验证,已生成可部署交付物。" - ) - if completion.status in {"partial", "blocked", "failed"}: - payload = { - "text": ( - "\n\n本轮仍有待处理事项:" - f"{completion.summary}。你可以在当前对话中继续完善。" - ) - } - yield ( - "event: delta\n" - f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" - ) - yield _progress_sse("正在生成安全的源码快照。") - failure_stage = "delivery_publish" - delivery = await DeliveryPublisher(transport).publish( - session_id=cloud.instance_id, - project_root=project_root, - task_root=lease.root, - completion=completion, - exact_secrets=lease.exact_secrets, - acceptance_criteria=decision.acceptance_criteria, - trusted_manifest_metadata=trusted_manifest_metadata, - ) - stored_version = None - persistence_error: dict[str, object] | None = None - if project_service is not None: - failure_stage = "version_persist" - yield _progress_sse("正在保存项目版本。") - try: - _, stored_version = await project_service.persist_delivery( - owner_id=owner, - session_id=session_id, - transport=transport, - delivery=delivery, - decision=decision, - ) - except PROJECT_EXCEPTIONS as error: - _, persistence_error = project_error_detail(error) - if persistence_error["code"] == ( - "INTELLIGENT_DEVELOPMENT_STORAGE_UNAVAILABLE" - ): - persistence_error["message"] = ( - "源码已生成,但项目版本暂时无法保存。" - "请保留当前开发环境并稍后重试。" - ) - logger.warning( - "Failed to persist intelligent-development version for %s: %s", - session_id, - type(error).__name__, - ) - except SandboxError as error: - persistence_error = _stream_error_payload(error) - logger.warning( - "Failed to read intelligent-development delivery for %s: %s", - session_id, - type(error).__name__, - ) - except ValueError as error: - persistence_error = { - "code": "INTELLIGENT_DEVELOPMENT_VERSION_INVALID", - "message": "源码已生成,但未通过项目版本完整性校验。", - "retryable": False, - } - logger.warning( - "Rejected intelligent-development version for %s: %s", - session_id, - type(error).__name__, - ) - except Exception: # noqa: BLE001 - terminal SSE boundary - persistence_error = { - "code": "INTELLIGENT_DEVELOPMENT_VERSION_SAVE_FAILED", - "message": "源码已生成,但项目版本保存失败。请稍后重试。", - "retryable": True, - } - logger.exception( - "Unexpected intelligent-development persistence failure for %s", - session_id, - ) - - failure_stage = "cleanup" - await cleanup_task_files() - - if delivery is not None: - source_delivery = delivery.as_dict() - if stored_version is not None: - source_delivery.update( - { - "projectId": stored_version.project_id, - "versionId": stored_version.version_id, - "parentVersionId": stored_version.parent_version_id, - } - ) - source_delivery["verified"] = False - if completion is not None and completion.verified: - source_delivery["validationSummary"] = "正在确认验证状态" - source_event = {"payload": {"delivery": source_delivery}} - yield ( - "event: development.source_ready\n" - f"data: {json.dumps(source_event, ensure_ascii=False)}\n\n" - ) - if persistence_error is not None: - yield ( - "event: error\n" - f"data: {json.dumps(persistence_error, ensure_ascii=False)}\n\n" - ) - yield 'event: done\ndata: {"reason":"failed"}\n\n' - return - if ( - delivery is not None - and completion is not None - and completion.verified - ): - succeeded_delivery = delivery.as_dict() - if stored_version is not None: - succeeded_delivery.update( - { - "projectId": stored_version.project_id, - "versionId": stored_version.version_id, - "parentVersionId": stored_version.parent_version_id, - } - ) - event = { - "payload": {"delivery": succeeded_delivery}, - } - yield ( - "event: development.succeeded\n" - f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - ) - failure_stage = "complete" - yield "event: done\ndata: {}\n\n" - except (asyncio.CancelledError, GeneratorExit) as error: - logger.warning( - "Intelligent development stream reason=%s stage=%s session_id=%s thread_id=%s elapsed_seconds=%.3f", - "task_cancelled" - if isinstance(error, asyncio.CancelledError) - else "stream_closed", - failure_stage, - session_id, - thread_id, - time.monotonic() - started_at, - ) - raise - except SandboxError as error: - failure = error - try: - await cleanup_task_files() - except SandboxError as cleanup_error: - failure = cleanup_error - except Exception: # noqa: BLE001 - failure = SandboxError( - "智能开发任务未能安全清理,请勿继续使用当前会话。" - ) - logger.warning( - "Intelligent development turn failed stage=%s code=%s error_type=%s session_id=%s thread_id=%s elapsed_seconds=%.3f", - failure_stage, - failure.code, - type(failure).__name__, - session_id, - thread_id, - time.monotonic() - started_at, - ) - payload = _stream_error_payload(failure) - yield f"event: error\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" - yield 'event: done\ndata: {"reason":"failed"}\n\n' - except Exception as error: # noqa: BLE001 - logger.error( - "Unexpected intelligent development turn failure stage=%s error_type=%s session_id=%s thread_id=%s elapsed_seconds=%.3f", - failure_stage, - type(error).__name__, - session_id, - thread_id, - time.monotonic() - started_at, - ) - try: - await cleanup_task_files() - except SandboxError as cleanup_error: - payload = _stream_error_payload(cleanup_error) - except Exception: # noqa: BLE001 - payload = { - "code": "INTELLIGENT_DEVELOPMENT_CLEANUP_FAILED", - "message": "智能开发任务未能安全清理,请勿继续使用当前会话。", - "retryable": False, - } - else: - payload = { - "code": "INTELLIGENT_DEVELOPMENT_FAILED", - "message": "智能开发任务未能安全完成,请在当前会话重试。", - "retryable": True, - } - yield f"event: error\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" - yield 'event: done\ndata: {"reason":"failed"}\n\n' - finally: - if completion_path or lease is not None: - try: - await cleanup_task_files() - except Exception: # noqa: BLE001 - logger.error( - "Final intelligent development cleanup failed for session %s", - session_id, - ) - async with task_locks_guard: - if task_lock.locked(): - task_lock.release() - task_locks.pop(lock_key, None) - logger.info( - "Intelligent development stream ended stage=%s session_id=%s thread_id=%s elapsed_seconds=%.3f", - failure_stage, - session_id, - thread_id, - time.monotonic() - started_at, - ) + async def stream(): + async for event in run_service.subscribe(owner, run.id): + if event is None: + yield ": heartbeat\n\n" + continue + payload = {**event["payload"], "runId": run.id, "seq": event["seq"]} + yield f"id: {event['seq']}\nevent: {event['type']}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + yield "event: done\ndata: {}\n\n" return StreamingResponse( - _with_sse_heartbeat(stream()), + stream(), media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + headers={ + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + "X-Development-Run-Id": run.id, + }, ) app.mount( @@ -1816,6 +1564,7 @@ async def _cleanup_loop() -> None: async def _start() -> None: nonlocal cleanup_task cleanup_task = asyncio.create_task(_cleanup_loop()) + await run_service.start() async def _stop() -> None: logger.info("Intelligent development service reason=studio_shutdown") @@ -1823,10 +1572,22 @@ async def _stop() -> None: cleanup_task.cancel() with contextlib.suppress(asyncio.CancelledError): await cleanup_task + await run_service.close() await service.close_all() - app.router.on_startup.append(_start) - app.router.on_shutdown.append(_stop) + previous_lifespan = app.router.lifespan_context + + @contextlib.asynccontextmanager + async def task_lifespan(current_app): + # Studio/ADK supplies a custom lifespan, which bypasses on_startup hooks. + async with previous_lifespan(current_app): + await _start() + try: + yield + finally: + await _stop() + + app.router.lifespan_context = task_lifespan __all__ = [ diff --git a/frontend/server/intelligent_development_runs/README.md b/frontend/server/intelligent_development_runs/README.md new file mode 100644 index 000000000..9ed679bb7 --- /dev/null +++ b/frontend/server/intelligent_development_runs/README.md @@ -0,0 +1,45 @@ +# Build result submission + +New build sessions use `tool-v1`. The worker starts a Codex thread with the +`submit_build_result` dynamic function; the empty thread prepared for the UI has +not accepted user work and is not reused. Codex 0.154.0 registers dynamic tools +on `thread/start`, restores them on `thread/resume`, and replays outstanding +`item/tool/call` requests. Ordinary Sandbox chat has no callback or added tool. + +The tool validates the complete reporting schema and measured verification +requirements. Studio supplies owner/run/lease identity, binds the native +thread/turn/call identifiers, and checks the model's revision echo against both +the accepted revision and queued/sending inputs. Only redacted completion +metadata and a digest of the arguments enter SQLite. Results and success replies +commit together before acknowledgement, so a replay cannot duplicate delivery. +The extra table cascades with the existing short-lived run retention. + +A successful native turn consumes its saved result without reading a model-named +file or starting another development turn. Missing metadata permits one automatic +reporting turn, limited to 120 seconds, with read-only filesystem permissions, +network disabled, and instructions to reuse measured evidence. It cannot rerun +the full builder prompt. An explicit user resume can retry reporting; a new user +requirement starts its own development cycle. A failed/interrupted development +turn still follows native recovery decisions and keeps its actual metrics. + +Steer messages carry an input revision and invalidate earlier submissions. +Publication checks revisions before packaging and version persistence; the final +artifact event and task completion share a SQLite transaction that rejects stop, +newer revisions, and pending/sending inputs. A concurrently superseded immutable +version may already have been saved, but it is not emitted as the current result. +Remote packaging remains reconciled through the existing deterministic delivery +ID and `RunShell` execution receipt. + +The UI preserves the native turn summary while displaying artifact preparation, +version saving, or reporting recovery. Stop remains available throughout. The +artifact card appears after saving the version; preparation time is not added to +native turn or tool measurements. + +Compatibility is thread-scoped: runs without a marker and existing `file-v1` +threads retain their file protocol, including follow-ups. Codex 0.154.0 cannot +add dynamic tools to an existing thread via resume. Start a new build session to +use the new protocol. The migration adds a table and per-run checkpoint marker; +it does not alter sandbox images or discard old rows. Older Studio code can read +the database but cannot execute active `tool-v1` runs: drain them before rolling +back. With the currently accepted ephemeral single-instance deployment, instance +replacement clears all short-term run records as before. diff --git a/frontend/server/intelligent_development_runs/__init__.py b/frontend/server/intelligent_development_runs/__init__.py new file mode 100644 index 000000000..11aff5483 --- /dev/null +++ b/frontend/server/intelligent_development_runs/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Short-lived, owner-scoped intelligent development tasks.""" diff --git a/frontend/server/intelligent_development_runs/models.py b/frontend/server/intelligent_development_runs/models.py new file mode 100644 index 000000000..1eeffac3b --- /dev/null +++ b/frontend/server/intelligent_development_runs/models.py @@ -0,0 +1,76 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Persisted task state, independent of browser and Codex connections.""" + +from dataclasses import dataclass, field +from typing import Any + +TERMINAL_STATES = frozenset({"succeeded", "cancelled", "failed"}) +RUN_STATES = TERMINAL_STATES | { + "queued", + "running", + "recovering", + "waiting_user", + "stopping", +} + + +@dataclass(frozen=True) +class Run: + id: str + owner_id: str + session_id: str + request_id: str + message: str + state: str + phase: str + thread_id: str + turn_id: str + input_revision: int + last_seq: int + stop_requested: bool + created_at: float + updated_at: float + expires_at: float | None + lease_token: str + lease_until: float + checkpoint: dict[str, Any] = field(default_factory=dict) + state_message: str = "" + message_digest: str = "" + + @property + def terminal(self) -> bool: + return self.state in TERMINAL_STATES + + def public(self) -> dict[str, Any]: + """Never expose executor leases, private paths or recovery checkpoints.""" + return { + "runId": self.id, + "sessionId": self.session_id, + "requestId": self.request_id, + "message": self.message, + "state": self.state, + "phase": self.phase, + "threadId": self.thread_id, + "turnId": self.turn_id, + "inputRevision": self.input_revision, + "lastSeq": self.last_seq, + "stopRequested": self.stop_requested, + "createdAt": self.created_at, + "updatedAt": self.updated_at, + "expiresAt": self.expires_at, + "statusMessage": self.state_message, + "issue": self.checkpoint.get("issue"), + } diff --git a/frontend/server/intelligent_development_runs/output.py b/frontend/server/intelligent_development_runs/output.py new file mode 100644 index 000000000..108accc48 --- /dev/null +++ b/frontend/server/intelligent_development_runs/output.py @@ -0,0 +1,106 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Buffer credential boundaries before any streamed text reaches SQLite.""" + +from dataclasses import replace + +from veadk.cli.codex_app_server import CodexAppServerEvent + + +class TaskTextProjection: + def __init__(self, secrets: tuple[str, ...]) -> None: + self.secrets = tuple(value for value in secrets if value) + self.pending: dict[str, str] = {} + self.visible: dict[str, str] = {} + self.events: dict[str, CodexAppServerEvent] = {} + + def apply( + self, event: CodexAppServerEvent, turn_id: str, *, snapshot_only: bool = False + ) -> CodexAppServerEvent: + item_id = event.item_id or f"assistant:{turn_id}" + self.events[item_id] = event + prior = self.pending.get(item_id, "") + is_delta = event.kind in {"text", "tool_output"} + text = prior + event.text if is_delta else event.text + if not is_delta and prior.startswith(text): + text = prior + self.pending[item_id] = text + final = event.kind == "assistant_final" or ( + event.kind in {"text_snapshot", "commentary", "thinking"} + and event.status == "done" + ) + if snapshot_only: + return replace( + event, item_id=item_id, text=self._public(text, final), turn_id=turn_id + ) + return self._emit(event, item_id, self._public(text, final), turn_id) + + def _emit( + self, event: CodexAppServerEvent, item_id: str, visible: str, turn_id: str + ) -> CodexAppServerEvent: + prior = self.visible.get(item_id) + self.visible[item_id] = visible + # The first native snapshot replaces previously persisted text on recovery. + # Subsequent stable suffixes avoid storing the whole answer for each token. + if (prior is not None and visible.startswith(prior)) or ( + prior is None and event.kind in {"text", "tool_output"} + ): + return replace( + event, + kind="tool_output" if event.kind.startswith("tool_output") else "text", + item_id=item_id, + text=visible[len(prior or "") :], + turn_id=turn_id, + ) + return replace( + event, + kind="tool_output_snapshot" + if event.kind.startswith("tool_output") + else "text_snapshot", + item_id=item_id, + text=visible, + turn_id=turn_id, + ) + + def _public(self, text: str, final: bool) -> str: + end = ( + len(text) + if final + else max(0, len(text) - max(map(len, self.secrets), default=1) + 1) + ) + for secret in self.secrets: + position = text.find(secret) + while position >= 0: + if position < end < position + len(secret): + end = position + position = text.find(secret, position + 1) + visible = text[:end] + for secret in sorted(self.secrets, key=len, reverse=True): + visible = visible.replace(secret, "***") + return visible + + def finish(self, turn_id: str) -> list[CodexAppServerEvent]: + return [ + replace( + self.events[item], + kind="tool_output_snapshot" + if self.events[item].kind.startswith("tool_output") + else "text_snapshot", + item_id=item, + text=self._public(text, True), + turn_id=turn_id, + ) + for item, text in self.pending.items() + ] diff --git a/frontend/server/intelligent_development_runs/repository.py b/frontend/server/intelligent_development_runs/repository.py new file mode 100644 index 000000000..927e9abcf --- /dev/null +++ b/frontend/server/intelligent_development_runs/repository.py @@ -0,0 +1,1044 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""SQLite acceptance, user isolation, fencing and replay for Studio tasks. + +Every user-facing operation requires an owner. A short transaction owns each +mutation; neither remote calls nor event subscribers hold SQLite transactions. +Executor leases fence database writes, not remote RPCs: the runner must still +reconcile an ambiguous remote submission before retrying it. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import sqlite3 +import time +from collections.abc import Callable +from contextlib import contextmanager +from pathlib import Path +from typing import Any, TypeVar +from uuid import uuid4 + +from .models import RUN_STATES, TERMINAL_STATES, Run + + +class RunNotFound(LookupError): + pass + + +class RunConflict(ValueError): + pass + + +class RunLeaseLost(RuntimeError): + pass + + +class RunCapacity(RunConflict): + pass + + +_T = TypeVar("_T") +_TERMINAL_SQL = "('succeeded', 'cancelled', 'failed')" +_INPUT_STATES = frozenset({"pending", "sending", "delivered", "withdrawn"}) + + +class RunRepository: + def __init__( + self, + path: str | Path, + *, + retention_seconds: float = 21_600, + max_active_seconds: float = 28_800, + max_event_bytes: int = 64 * 1024 * 1024, + clock: Callable[[], float] = time.time, + ) -> None: + if retention_seconds <= 0 or max_active_seconds <= 0 or max_event_bytes <= 0: + raise ValueError("Task retention must be positive") + self.path = Path(path).expanduser().absolute() + self.retention_seconds = retention_seconds + self.max_active_seconds = max_active_seconds + self.max_event_bytes = max_event_bytes + self.clock = clock + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + # Open with restrictive permissions even when the process umask is broad. + descriptor = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600) + os.close(descriptor) + with self._connection() as db: + db.execute("PRAGMA journal_mode=WAL") + db.executescript(f""" + CREATE TABLE IF NOT EXISTS runs ( + owner_id TEXT NOT NULL, + id TEXT NOT NULL, + session_id TEXT NOT NULL, + request_id TEXT NOT NULL, + message TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'queued', + phase TEXT NOT NULL DEFAULT 'prepare', + thread_id TEXT NOT NULL DEFAULT '', + turn_id TEXT NOT NULL DEFAULT '', + input_revision INTEGER NOT NULL DEFAULT 1, + last_seq INTEGER NOT NULL DEFAULT 0, + stop_requested INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + expires_at REAL, + lease_token TEXT NOT NULL DEFAULT '', + lease_until REAL NOT NULL DEFAULT 0, + checkpoint TEXT NOT NULL DEFAULT '{{}}', + state_message TEXT NOT NULL DEFAULT '', + PRIMARY KEY (owner_id, id), + UNIQUE (owner_id, session_id, request_id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS one_active_run + ON runs(owner_id, session_id) + WHERE state NOT IN {_TERMINAL_SQL}; + CREATE INDEX IF NOT EXISTS run_expiry ON runs(expires_at); + CREATE TABLE IF NOT EXISTS run_events ( + owner_id TEXT NOT NULL, + run_id TEXT NOT NULL, + seq INTEGER NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (owner_id, run_id, seq), + FOREIGN KEY (owner_id, run_id) REFERENCES runs(owner_id, id) + ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS run_inputs ( + owner_id TEXT NOT NULL, + run_id TEXT NOT NULL, + client_id TEXT NOT NULL, + revision INTEGER NOT NULL, + message TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + turn_id TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL, + PRIMARY KEY (owner_id, run_id, client_id), + UNIQUE (owner_id, run_id, revision), + FOREIGN KEY (owner_id, run_id) REFERENCES runs(owner_id, id) + ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS run_event_usage ( + owner_id TEXT NOT NULL, run_id TEXT NOT NULL, bytes INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(owner_id,run_id), + FOREIGN KEY(owner_id,run_id) REFERENCES runs(owner_id,id) ON DELETE CASCADE + ); + INSERT OR IGNORE INTO run_event_usage + SELECT owner_id,run_id,SUM(length(CAST(payload AS BLOB))) FROM run_events GROUP BY owner_id,run_id; + CREATE TABLE IF NOT EXISTS run_turns ( + owner_id TEXT NOT NULL, + run_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + input_revision INTEGER NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (owner_id, run_id, turn_id), + FOREIGN KEY (owner_id, run_id) REFERENCES runs(owner_id, id) + ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS run_results ( + owner_id TEXT NOT NULL, run_id TEXT NOT NULL, + thread_id TEXT NOT NULL, turn_id TEXT NOT NULL, call_id TEXT NOT NULL, + input_revision INTEGER NOT NULL, digest TEXT NOT NULL, + completion TEXT NOT NULL, response TEXT NOT NULL, + created_at REAL NOT NULL, + PRIMARY KEY (owner_id, run_id, thread_id, turn_id, call_id), + FOREIGN KEY (owner_id, run_id) REFERENCES runs(owner_id, id) ON DELETE CASCADE + ); + PRAGMA user_version=1; + """) + # Expand existing local databases without discarding retained runs. + for table in ("runs", "run_inputs"): + columns = { + row["name"] for row in db.execute(f"PRAGMA table_info({table})") + } + if "message_digest" not in columns: + db.execute( + f"ALTER TABLE {table} ADD COLUMN message_digest TEXT NOT NULL DEFAULT ''" + ) + + if "metrics" not in { + row["name"] for row in db.execute("PRAGMA table_info(run_turns)") + }: + db.execute( + "ALTER TABLE run_turns ADD COLUMN metrics TEXT NOT NULL DEFAULT '{}'" + ) + + @contextmanager + def _connection(self): + db = sqlite3.connect(self.path, timeout=5, isolation_level=None) + db.row_factory = sqlite3.Row + db.execute("PRAGMA foreign_keys=ON") + db.execute("PRAGMA busy_timeout=5000") + try: + yield db + finally: + db.close() + + async def _read(self, operation: Callable[[sqlite3.Connection], _T]) -> _T: + def run() -> _T: + with self._connection() as db: + return operation(db) + + return await asyncio.to_thread(run) + + async def _write(self, operation: Callable[[sqlite3.Connection], _T]) -> _T: + def run() -> _T: + with self._connection() as db: + db.execute("BEGIN IMMEDIATE") + try: + result = operation(db) + db.commit() + return result + except BaseException: + db.rollback() + raise + + return await asyncio.to_thread(run) + + @staticmethod + def _owned(db: sqlite3.Connection, owner: str, run_id: str) -> Run: + row = db.execute( + "SELECT * FROM runs WHERE owner_id=? AND id=?", (owner, run_id) + ).fetchone() + if row is None: + raise RunNotFound("任务不存在或不属于当前用户。") + value = dict(row) + value["checkpoint"] = json.loads(value["checkpoint"]) + value["stop_requested"] = bool(value["stop_requested"]) + return Run(**value) + + def _leased( + self, db: sqlite3.Connection, owner: str, run_id: str, token: str + ) -> Run: + run = self._owned(db, owner, run_id) + if not token or run.lease_token != token or run.lease_until <= self.clock(): + raise RunLeaseLost("Task executor lease is no longer valid") + return run + + def _event( + self, + db: sqlite3.Connection, + owner: str, + run_id: str, + event_type: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + if event_type == "run.status": + payload = { + **payload, + "lastSeq": self._owned(db, owner, run_id).last_seq + 1, + } + encoded = json.dumps(payload, ensure_ascii=False, allow_nan=False) + size = len(encoded.encode()) + if size > 2 * 1024 * 1024: + raise ValueError("Task event exceeds the size limit") + usage = db.execute( + "SELECT bytes FROM run_event_usage WHERE owner_id=? AND run_id=?", + (owner, run_id), + ).fetchone() + if ( + usage + and usage["bytes"] + size > self.max_event_bytes + and not event_type.startswith("run.") + ): + raise RunCapacity("任务输出已达到短期存储上限,已保留现有内容。") + db.execute( + "INSERT INTO run_event_usage VALUES(?,?,?) ON CONFLICT(owner_id,run_id) DO UPDATE SET bytes=bytes+excluded.bytes", + (owner, run_id, size), + ) + db.execute( + "UPDATE runs SET last_seq=last_seq+1, updated_at=? WHERE owner_id=? AND id=?", + (self.clock(), owner, run_id), + ) + run = self._owned(db, owner, run_id) + db.execute( + "INSERT INTO run_events VALUES (?, ?, ?, ?, ?, ?)", + (owner, run_id, run.last_seq, event_type, encoded, self.clock()), + ) + return {"seq": run.last_seq, "type": event_type, "payload": payload} + + async def create( + self, + owner: str, + session_id: str, + request_id: str, + message: str, + *, + thread_id: str = "", + message_digest: str = "", + result_protocol: str = "file-v1", + ) -> Run: + if not owner or not session_id or not request_id or not message.strip(): + raise ValueError("Task identity and message are required") + if len(message) > 100_000 or len(request_id) > 128: + raise ValueError("Task input exceeds the size limit") + + digest = message_digest or hashlib.sha256(message.encode()).hexdigest() + if result_protocol not in {"file-v1", "tool-v1"}: + raise ValueError("Unknown result protocol") + + def operation(db): + existing = db.execute( + "SELECT id FROM runs WHERE owner_id=? AND session_id=? AND request_id=?", + (owner, session_id, request_id), + ).fetchone() + if existing: + run = self._owned(db, owner, existing["id"]) + if (run.message_digest and run.message_digest != digest) or ( + not run.message_digest and run.message != message + ): + raise RunConflict("相同请求标识不能用于不同内容。") + return run + active = db.execute( + f"SELECT owner_id FROM runs WHERE state NOT IN {_TERMINAL_SQL}" + ).fetchall() + if ( + len(active) >= 100 + or sum(row["owner_id"] == owner for row in active) >= 3 + ): + raise RunCapacity("当前进行中的任务较多,请先完成或停止其他任务。") + pages = db.execute("PRAGMA page_count").fetchone()[0] + pages -= db.execute("PRAGMA freelist_count").fetchone()[0] + page_size = db.execute("PRAGMA page_size").fetchone()[0] + if pages * page_size >= 512 * 1024 * 1024: + raise RunCapacity("本地任务存储暂时已满,请稍后重试。") + run_id = uuid4().hex + try: + db.execute( + "INSERT INTO runs(owner_id,id,session_id,request_id,message,thread_id,created_at,updated_at,message_digest,checkpoint) " + "VALUES(?,?,?,?,?,?,?,?,?,?)", + ( + owner, + run_id, + session_id, + request_id, + message, + thread_id, + self.clock(), + self.clock(), + digest, + json.dumps({"result_protocol": result_protocol}), + ), + ) + except sqlite3.IntegrityError as error: + raise RunConflict( + "当前环境已有任务,请追加要求或先停止任务。" + ) from error + db.execute( + "INSERT INTO run_inputs(owner_id,run_id,client_id,revision,message,created_at,message_digest) VALUES(?,?,?,?,?,?,?)", + (owner, run_id, request_id, 1, message, self.clock(), digest), + ) + self._event( + db, + owner, + run_id, + "run.input", + { + "clientId": request_id, + "revision": 1, + "message": message, + "status": "pending", + }, + ) + return self._owned(db, owner, run_id) + + return await self._write(operation) + + async def get(self, owner: str, run_id: str) -> Run: + return await self._read(lambda db: self._owned(db, owner, run_id)) + + async def session_runs(self, owner: str, session_id: str) -> list[Run]: + def operation(db): + return [ + self._owned(db, owner, row["id"]) + for row in db.execute( + "SELECT id FROM runs WHERE owner_id=? AND session_id=? ORDER BY created_at,id", + (owner, session_id), + ) + ] + + return await self._read(operation) + + async def recoverable(self) -> list[Run]: + """Internal scheduler scan; this method is never an HTTP list endpoint.""" + + def operation(db): + return [ + self._owned(db, row["owner_id"], row["id"]) + for row in db.execute( + f"SELECT owner_id,id FROM runs WHERE state NOT IN {_TERMINAL_SQL} " + "AND (state != 'waiting_user' OR created_at<=?) ORDER BY created_at", + (self.clock() - self.max_active_seconds,), + ) + ] + + return await self._read(operation) + + async def active_for_owner(self, owner: str) -> list[Run]: + return await self._read( + lambda db: [ + self._owned(db, owner, row["id"]) + for row in db.execute( + f"SELECT id FROM runs WHERE owner_id=? AND state NOT IN {_TERMINAL_SQL} ORDER BY created_at", + (owner,), + ) + ] + ) + + async def claim( + self, owner: str, run_id: str, *, seconds: float = 30 + ) -> str | None: + def operation(db): + run = self._owned(db, owner, run_id) + if run.terminal or (run.lease_token and run.lease_until > self.clock()): + return None + token = uuid4().hex + db.execute( + "UPDATE runs SET lease_token=?,lease_until=? WHERE owner_id=? AND id=?", + (token, self.clock() + seconds, owner, run_id), + ) + return token + + return await self._write(operation) + + async def heartbeat( + self, owner: str, run_id: str, token: str, *, seconds: float = 30 + ) -> None: + def operation(db): + self._leased(db, owner, run_id, token) + db.execute( + "UPDATE runs SET lease_until=? WHERE owner_id=? AND id=?", + (self.clock() + seconds, owner, run_id), + ) + + await self._write(operation) + + async def release(self, owner: str, run_id: str, token: str) -> None: + await self._write( + lambda db: db.execute( + "UPDATE runs SET lease_token='',lease_until=0 WHERE owner_id=? AND id=? AND lease_token=?", + (owner, run_id, token), + ).rowcount + ) + + async def update( + self, + owner: str, + run_id: str, + token: str, + *, + checkpoint_updates: dict[str, Any] | None = None, + **changes: Any, + ) -> Run: + allowed = { + "state", + "phase", + "thread_id", + "turn_id", + "checkpoint", + "state_message", + } + if not changes or set(changes) - allowed: + raise ValueError("Unsupported task update") + if "state" in changes and changes["state"] not in RUN_STATES: + raise ValueError("Invalid task state") + + def operation(db): + run = self._leased(db, owner, run_id, token) + if run.terminal: + raise RunConflict("任务已经结束。") + state = changes.get("state", run.state) + if run.stop_requested and state not in {"stopping", "cancelled", "failed"}: + raise RunConflict("任务正在停止。") + values = dict(changes) + if checkpoint_updates is not None: + if "checkpoint" in values: + raise ValueError("Cannot replace and patch a checkpoint together") + values["checkpoint"] = {**run.checkpoint, **checkpoint_updates} + if "checkpoint" in values: + values["checkpoint"] = json.dumps( + values["checkpoint"], ensure_ascii=False, allow_nan=False + ) + values["updated_at"] = self.clock() + if state in TERMINAL_STATES: + values["expires_at"] = self.clock() + self.retention_seconds + assignments = ",".join(f"{key}=?" for key in values) + db.execute( + f"UPDATE runs SET {assignments} WHERE owner_id=? AND id=?", + (*values.values(), owner, run_id), + ) + updated = self._owned(db, owner, run_id) + if any(key in changes for key in ("state", "phase", "state_message")): + self._event(db, owner, run_id, "run.status", updated.public()) + return self._owned(db, owner, run_id) + + return await self._write(operation) + + async def append_event( + self, + owner: str, + run_id: str, + token: str, + event_type: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + def operation(db): + self._leased(db, owner, run_id, token) + return self._event(db, owner, run_id, event_type, payload) + + return await self._write(operation) + + async def events( + self, owner: str, run_id: str, *, after: int = 0, limit: int = 500 + ) -> list[dict[str, Any]]: + if after < 0 or not 1 <= limit <= 1000: + raise ValueError("Invalid event cursor or limit") + + def operation(db): + self._owned(db, owner, run_id) + return [ + { + "seq": row["seq"], + "type": row["type"], + "payload": json.loads(row["payload"]), + } + for row in db.execute( + "SELECT seq,type,payload FROM run_events WHERE owner_id=? AND run_id=? AND seq>? ORDER BY seq LIMIT ?", + (owner, run_id, after, limit), + ) + ] + + return await self._read(operation) + + async def request_stop(self, owner: str, run_id: str) -> Run: + def operation(db): + run = self._owned(db, owner, run_id) + if run.terminal or run.stop_requested: + return run + db.execute( + "UPDATE runs SET stop_requested=1,state='stopping',state_message=?,updated_at=? WHERE owner_id=? AND id=?", + ("正在停止任务。", self.clock(), owner, run_id), + ) + pending = db.execute( + "SELECT client_id FROM run_inputs WHERE owner_id=? AND run_id=? AND status='pending' ORDER BY revision", + (owner, run_id), + ).fetchall() + db.execute( + "UPDATE run_inputs SET status='withdrawn' WHERE owner_id=? AND run_id=? AND status='pending'", + (owner, run_id), + ) + for item in pending: + self._event( + db, + owner, + run_id, + "run.input_status", + { + "clientId": item["client_id"], + "status": "withdrawn", + "turnId": "", + }, + ) + self._event( + db, owner, run_id, "run.status", self._owned(db, owner, run_id).public() + ) + return self._owned(db, owner, run_id) + + return await self._write(operation) + + async def resume(self, owner: str, run_id: str) -> Run: + """Explicitly resume a paused task; completed/cancelled tasks get a new run.""" + + def operation(db): + run = self._owned(db, owner, run_id) + if run.state != "waiting_user" or run.stop_requested: + raise RunConflict("只有等待处理的任务可以恢复。") + db.execute( + "UPDATE runs SET state='recovering',state_message='',checkpoint=?,updated_at=? WHERE owner_id=? AND id=?", + ( + json.dumps({**run.checkpoint, "manual_resume": True}), + self.clock(), + owner, + run_id, + ), + ) + self._event( + db, owner, run_id, "run.status", self._owned(db, owner, run_id).public() + ) + return self._owned(db, owner, run_id) + + return await self._write(operation) + + async def add_input( + self, + owner: str, + run_id: str, + client_id: str, + message: str, + *, + message_digest: str = "", + ) -> dict[str, Any]: + if ( + not client_id + or len(client_id) > 128 + or not message.strip() + or len(message) > 100_000 + ): + raise ValueError("Invalid additional input") + digest = message_digest or hashlib.sha256(message.encode()).hexdigest() + + def operation(db): + run = self._owned(db, owner, run_id) + existing = db.execute( + "SELECT * FROM run_inputs WHERE owner_id=? AND run_id=? AND client_id=?", + (owner, run_id, client_id), + ).fetchone() + if existing: + if ( + existing["message_digest"] and existing["message_digest"] != digest + ) or ( + not existing["message_digest"] and existing["message"] != message + ): + raise RunConflict("相同消息标识不能用于不同内容。") + return dict(existing) + if run.terminal or run.stop_requested: + raise RunConflict("任务已结束或正在停止,请保留输入并稍后发送。") + revision = run.input_revision + 1 + if revision > 256: + raise RunCapacity("本次任务的追加消息已达上限,请完成后再发起新任务。") + db.execute( + "UPDATE runs SET input_revision=? WHERE owner_id=? AND id=?", + (revision, owner, run_id), + ) + db.execute( + "INSERT INTO run_inputs(owner_id,run_id,client_id,revision,message,created_at,message_digest) VALUES(?,?,?,?,?,?,?)", + (owner, run_id, client_id, revision, message, self.clock(), digest), + ) + self._event( + db, + owner, + run_id, + "run.input", + { + "clientId": client_id, + "revision": revision, + "message": message, + "status": "pending", + }, + ) + if run.state == "waiting_user": + db.execute( + "UPDATE runs SET state='recovering',checkpoint=? WHERE owner_id=? AND id=?", + ( + json.dumps({**run.checkpoint, "manual_resume": True}), + owner, + run_id, + ), + ) + self._event( + db, + owner, + run_id, + "run.status", + self._owned(db, owner, run_id).public(), + ) + return dict( + db.execute( + "SELECT * FROM run_inputs WHERE owner_id=? AND run_id=? AND client_id=?", + (owner, run_id, client_id), + ).fetchone() + ) + + return await self._write(operation) + + async def inputs( + self, owner: str, run_id: str, *, statuses: tuple[str, ...] = () + ) -> list[dict[str, Any]]: + if set(statuses) - _INPUT_STATES: + raise ValueError("Invalid input status") + + def operation(db): + self._owned(db, owner, run_id) + condition = ( + " AND status IN (" + ",".join("?" for _ in statuses) + ")" + if statuses + else "" + ) + return [ + dict(row) + for row in db.execute( + "SELECT * FROM run_inputs WHERE owner_id=? AND run_id=?" + + condition + + " ORDER BY revision", + (owner, run_id, *statuses), + ) + ] + + return await self._read(operation) + + async def input_status( + self, + owner: str, + run_id: str, + token: str, + client_id: str, + status: str, + *, + turn_id: str = "", + ) -> None: + if status not in _INPUT_STATES: + raise ValueError("Invalid input status") + + def operation(db): + run = self._leased(db, owner, run_id, token) + item = db.execute( + "SELECT status,turn_id FROM run_inputs WHERE owner_id=? AND run_id=? AND client_id=?", + (owner, run_id, client_id), + ).fetchone() + if item is None: + raise RunNotFound("任务消息不存在。") + if (run.stop_requested and status in {"pending", "sending"}) or item[ + "status" + ] == "withdrawn": + raise RunConflict("任务正在停止,不能继续发送。") + if item["status"] == status and item["turn_id"] == turn_id: + return + if item["status"] == "delivered" and status != "delivered": + raise RunConflict("已送达的消息不能重新发送。") + changed = db.execute( + "UPDATE run_inputs SET status=?,turn_id=? WHERE owner_id=? AND run_id=? AND client_id=?", + (status, turn_id, owner, run_id, client_id), + ).rowcount + if not changed: + raise RunNotFound("任务消息不存在。") + self._event( + db, + owner, + run_id, + "run.input_status", + {"clientId": client_id, "status": status, "turnId": turn_id}, + ) + + await self._write(operation) + + async def record_turn( + self, + owner: str, + run_id: str, + token: str, + *, + thread_id: str, + turn_id: str, + revision: int, + status: str, + metrics: dict[str, Any] | None = None, + ) -> None: + """Persist one native turn and its usage atomically with replayable events.""" + if not turn_id: + return + + def operation(db): + run = self._leased(db, owner, run_id, token) + row = db.execute( + "SELECT status,metrics FROM run_turns WHERE owner_id=? AND run_id=? AND turn_id=?", + (owner, run_id, turn_id), + ).fetchone() + previous = json.loads(row["metrics"]) if row else {} + value = dict(previous) + update = metrics or {} + for key in ("startedAt", "completedAt", "durationMs"): + number = update.get(key) + if ( + isinstance(number, (int, float)) + and not isinstance(number, bool) + and 0 <= number < 2**53 + ): + value[key] = number + if isinstance(update.get("model"), str) and update["model"]: + value["model"] = update["model"][:200] + if update.get("resumed") and not row: + value["usageIncomplete"] = True + usage = update.get("usage") + total = update.get("threadTotal") + if isinstance(usage, dict): + keys = ( + "totalTokens", + "inputTokens", + "outputTokens", + "cachedInputTokens", + "cacheWriteInputTokens", + "reasoningOutputTokens", + ) + usage = { + k: v + for k, v in usage.items() + if k in keys and type(v) is int and 0 <= v < 2**53 + } + total = ( + { + k: v + for k, v in total.items() + if k in keys and type(v) is int and 0 <= v < 2**53 + } + if isinstance(total, dict) + else {} + ) + baseline = run.checkpoint.get("token_total", {}) + if baseline.get("threadId") == thread_id and total: + before = baseline.get("usage", {}) + if all(total.get(k, v) >= v for k, v in before.items()): + delta = {k: v - before.get(k, 0) for k, v in total.items()} + value["usage"] = { + k: value.get("usage", {}).get(k, 0) + v + for k, v in delta.items() + } + else: + # A reset/compaction is not a negative usage measurement. + value["usageIncomplete"] = True + elif "usage" not in value: + value["usage"] = usage + else: + # A cumulative per-connection snapshot is not an increment. + value["usage"] = { + k: max(value["usage"].get(k, 0), v) for k, v in usage.items() + } + value["usageIncomplete"] = True + if total: + db.execute( + "UPDATE runs SET checkpoint=? WHERE owner_id=? AND id=?", + ( + json.dumps( + { + **run.checkpoint, + "token_total": { + "threadId": thread_id, + "usage": total, + }, + } + ), + owner, + run_id, + ), + ) + if row and row["status"] in {"completed", "failed", "interrupted"}: + final_status = row["status"] + else: + final_status = status + value.update(turnId=turn_id, status=final_status) + if final_status in {"failed", "interrupted"}: + value["usageIncomplete"] = True + db.execute( + "INSERT INTO run_turns(owner_id,run_id,turn_id,thread_id,input_revision,status,metrics) VALUES(?,?,?,?,?,?,?) " + "ON CONFLICT(owner_id,run_id,turn_id) DO UPDATE SET input_revision=excluded.input_revision,status=excluded.status,metrics=excluded.metrics", + ( + owner, + run_id, + turn_id, + thread_id, + revision, + final_status, + json.dumps(value), + ), + ) + if value != previous: + self._event(db, owner, run_id, "run.turn", value) + + await self._write(operation) + + async def cleanup(self) -> int: + return await self._write( + lambda db: db.execute( + f"DELETE FROM runs WHERE state IN {_TERMINAL_SQL} AND expires_at<=? AND lease_until<=?", + (self.clock(), self.clock()), + ).rowcount + ) + + async def finish(self, owner: str, run_id: str, token: str, revision: int) -> bool: + """Atomically fence completion against a concurrent steer or stop.""" + + def operation(db): + run = self._leased(db, owner, run_id, token) + if run.stop_requested or run.input_revision != revision: + return False + if run.checkpoint.get("result_protocol") == "tool-v1": + if ( + not self._revision_current(db, run, revision) + or run.checkpoint.get("completion_revision") != revision + ): + return False + delivery = run.checkpoint.get("version") + if delivery and not run.checkpoint.get("delivery_emitted"): + self._event( + db, + owner, + run_id, + "development.succeeded" + if delivery.get("verified") + else "development.source_ready", + {"payload": {"delivery": delivery}, "inputRevision": revision}, + ) + db.execute( + "UPDATE runs SET state='succeeded',phase='complete',state_message='',expires_at=?,checkpoint=? WHERE owner_id=? AND id=?", + ( + self.clock() + self.retention_seconds, + json.dumps({**run.checkpoint, "issue": None}), + owner, + run_id, + ), + ) + self._event( + db, owner, run_id, "run.status", self._owned(db, owner, run_id).public() + ) + return True + + return await self._write(operation) + + @staticmethod + def _revision_current(db: sqlite3.Connection, run: Run, revision: int) -> bool: + return ( + not run.stop_requested + and not run.terminal + and run.input_revision == revision + and not db.execute( + "SELECT 1 FROM run_inputs WHERE owner_id=? AND run_id=? AND status IN ('pending','sending') LIMIT 1", + (run.owner_id, run.id), + ).fetchone() + ) + + async def revision_current( + self, owner: str, run_id: str, token: str, revision: int + ) -> bool: + return await self._read( + lambda db: self._revision_current( + db, self._leased(db, owner, run_id, token), revision + ) + ) + + async def submit_result( + self, + owner: str, + run_id: str, + token: str, + *, + thread_id: str, + turn_id: str, + call_id: str, + revision: int, + digest: str, + completion: dict[str, Any], + response: dict[str, Any], + ) -> dict[str, Any]: + """Commit before acknowledging; the call identity survives lost RPC replies.""" + + def operation(db): + run = self._leased(db, owner, run_id, token) + if ( + run.checkpoint.get("result_protocol") != "tool-v1" + or run.phase not in {"coding", "reporting"} + or run.thread_id != thread_id + or run.turn_id != turn_id + ): + raise RunConflict("Result does not belong to the active build turn.") + if ( + not self._revision_current(db, run, revision) + or run.checkpoint.get("accepted_revision") != revision + ): + raise RunConflict( + "Requirements changed or the task stopped. Apply the latest accepted input revision before submitting." + ) + key = (owner, run_id, thread_id, turn_id, call_id) + existing = db.execute( + "SELECT digest,response FROM run_results WHERE owner_id=? AND run_id=? AND thread_id=? AND turn_id=? AND call_id=?", + key, + ).fetchone() + if existing: + if existing["digest"] != digest: + raise RunConflict( + "A tool call ID cannot be reused for different results." + ) + return json.loads(existing["response"]) + count = db.execute( + "SELECT COUNT(*) FROM run_results WHERE owner_id=? AND run_id=?", + (owner, run_id), + ).fetchone()[0] + if count >= 512: + raise RunCapacity("Result submission limit reached.") + db.execute( + "INSERT INTO run_results VALUES(?,?,?,?,?,?,?,?,?,?)", + ( + *key, + revision, + digest, + json.dumps(completion, ensure_ascii=False, allow_nan=False), + json.dumps(response, ensure_ascii=False, allow_nan=False), + self.clock(), + ), + ) + return response + + return await self._write(operation) + + async def result_for_turn( + self, owner: str, run_id: str, token: str + ) -> dict[str, Any] | None: + def operation(db): + run = self._leased(db, owner, run_id, token) + revision = int(run.checkpoint.get("accepted_revision", 0)) + if not self._revision_current(db, run, revision): + return None + row = db.execute( + "SELECT completion FROM run_results WHERE owner_id=? AND run_id=? AND thread_id=? AND turn_id=? AND input_revision=? ORDER BY rowid DESC LIMIT 1", + (owner, run_id, run.thread_id, run.turn_id, revision), + ).fetchone() + return json.loads(row["completion"]) if row else None + + return await self._read(operation) + + async def delete(self, owner: str, run_id: str) -> None: + def operation(db): + run = self._owned(db, owner, run_id) + if not run.terminal: + raise RunConflict("请先停止任务。") + db.execute("DELETE FROM runs WHERE owner_id=? AND id=?", (owner, run_id)) + + await self._write(operation) + + async def checkpoint( + self, owner: str, run_id: str, token: str, **values: Any + ) -> Run: + def operation(db): + run = self._leased(db, owner, run_id, token) + checkpoint = {**run.checkpoint, **values} + if "accepted_revision" in values: + checkpoint["accepted_revision"] = max( + int(run.checkpoint.get("accepted_revision", 0)), + int(values["accepted_revision"]), + ) + db.execute( + "UPDATE runs SET checkpoint=? WHERE owner_id=? AND id=?", + ( + json.dumps(checkpoint, ensure_ascii=False, allow_nan=False), + owner, + run_id, + ), + ) + return self._owned(db, owner, run_id) + + return await self._write(operation) diff --git a/frontend/server/intelligent_development_runs/routes.py b/frontend/server/intelligent_development_runs/routes.py new file mode 100644 index 000000000..0af2cc18f --- /dev/null +++ b/frontend/server/intelligent_development_runs/routes.py @@ -0,0 +1,204 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Authenticated submission and observation of short-lived build tasks.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Awaitable, Callable +from typing import Any + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import Response, StreamingResponse + +from .models import Run +from .repository import RunConflict, RunNotFound +from .service import RunService + + +def mount_run_routes( + app: FastAPI, + *, + prefix: str, + service: RunService, + owner_resolver: Callable[[Request], str], + prepare: Callable[[str, str], Awaitable[str]], + redact_message: Callable[[str], str], +) -> None: + repository = service.repository + + def owner(request: Request) -> str: + value = owner_resolver(request) + if not value: + raise HTTPException(401, "请先登录。") + return value + + async def body(request: Request, keys: set[str]) -> dict[str, str]: + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > 128 * 1024: + raise HTTPException(413, "消息过长。") + try: + value = json.loads(raw) + except (ValueError, UnicodeDecodeError) as error: + raise HTTPException(422, "请求不是有效 JSON。") from error + if not isinstance(value, dict) or set(value) != keys: + raise HTTPException(422, "请求字段无效。") + if any( + not isinstance(value[key], str) or not value[key].strip() for key in keys + ): + raise HTTPException(422, "请求字段不能为空。") + if ( + len(value.get("message", "")) > 100_000 + or len(value.get("requestId", value.get("clientId", ""))) > 128 + ): + raise HTTPException(422, "消息或标识过长。") + return value + + async def owned(request: Request, run_id: str) -> Run: + try: + return await repository.get(owner(request), run_id) + except RunNotFound as error: + raise HTTPException(404, "任务不存在。") from error + + @app.post(f"{prefix}/sessions/{{session_id}}/runs", status_code=202) + async def create(session_id: str, request: Request) -> dict[str, Any]: + identity = owner(request) + value = await body(request, {"message", "requestId"}) + # Ownership is checked against the environment before accepting remote work. + thread_id = await prepare(session_id, identity) + previous = await repository.session_runs(identity, session_id) + # 0.154.0 restores dynamic tools on resume but cannot add them to an + # existing thread. Keep legacy threads on their persisted protocol. + result_protocol = ( + previous[-1].checkpoint.get("result_protocol", "file-v1") + if previous + else "tool-v1" + ) + if previous and previous[-1].thread_id: + thread_id = previous[-1].thread_id + elif result_protocol == "tool-v1": + # /connect prepared an empty UI thread. The worker starts the build + # thread with its tools before accepting the first user turn. + thread_id = "" + try: + run = await repository.create( + identity, + session_id, + value["requestId"], + redact_message(value["message"].strip()), + thread_id=thread_id, + result_protocol=result_protocol, + message_digest=hashlib.sha256( + value["message"].strip().encode() + ).hexdigest(), + ) + except RunConflict as error: + raise HTTPException(409, str(error)) from error + service.launch(run) + return run.public() + + @app.get(f"{prefix}/sessions/{{session_id}}/runs") + async def list_runs(session_id: str, request: Request) -> dict[str, Any]: + runs = await repository.session_runs(owner(request), session_id) + return {"runs": [run.public() for run in runs]} + + @app.get(f"{prefix}/sessions/{{session_id}}/runs/current") + async def current(session_id: str, request: Request) -> dict[str, Any]: + runs = await repository.session_runs(owner(request), session_id) + return {"run": runs[-1].public() if runs else None} + + @app.get(f"{prefix}/runs") + async def active_runs(request: Request) -> dict[str, Any]: + return { + "runs": [ + run.public() + for run in await repository.active_for_owner(owner(request)) + ] + } + + @app.get(f"{prefix}/runs/{{run_id}}") + async def detail(run_id: str, request: Request) -> dict[str, Any]: + return (await owned(request, run_id)).public() + + @app.get(f"{prefix}/runs/{{run_id}}/events") + async def events( + run_id: str, request: Request, after: int = 0 + ) -> StreamingResponse: + run = await owned(request, run_id) + if after < 0 or after > run.last_seq: + raise HTTPException(422, "事件游标无效。") + + async def stream(): + async for event in service.subscribe(run.owner_id, run_id, after=after): + if event is None: + yield ": heartbeat\n\n" + else: + payload = {**event["payload"], "runId": run_id, "seq": event["seq"]} + yield f"id: {event['seq']}\nevent: {event['type']}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + yield "event: done\ndata: {}\n\n" + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + + @app.post(f"{prefix}/runs/{{run_id}}/stop", status_code=202) + async def stop(run_id: str, request: Request) -> dict[str, Any]: + run = await owned(request, run_id) + return (await service.stop(run.owner_id, run_id)).public() + + @app.post(f"{prefix}/runs/{{run_id}}/resume", status_code=202) + async def resume(run_id: str, request: Request) -> dict[str, Any]: + run = await owned(request, run_id) + try: + return (await service.resume(run.owner_id, run_id)).public() + except RunConflict as error: + raise HTTPException(409, str(error)) from error + + @app.post(f"{prefix}/runs/{{run_id}}/inputs", status_code=202) + async def steer(run_id: str, request: Request) -> dict[str, Any]: + run = await owned(request, run_id) + value = await body(request, {"message", "clientId"}) + try: + item = await repository.add_input( + run.owner_id, + run_id, + value["clientId"], + redact_message(value["message"].strip()), + message_digest=hashlib.sha256( + value["message"].strip().encode() + ).hexdigest(), + ) + except RunConflict as error: + raise HTTPException(409, str(error)) from error + service.launch(await repository.get(run.owner_id, run_id)) + return { + "clientId": item["client_id"], + "status": item["status"], + "revision": item["revision"], + } + + @app.delete(f"{prefix}/runs/{{run_id}}", status_code=204) + async def delete(run_id: str, request: Request) -> Response: + run = await owned(request, run_id) + try: + await repository.delete(run.owner_id, run_id) + except RunConflict as error: + raise HTTPException(409, str(error)) from error + return Response(status_code=204) diff --git a/frontend/server/intelligent_development_runs/runner.py b/frontend/server/intelligent_development_runs/runner.py new file mode 100644 index 000000000..3aaa63714 --- /dev/null +++ b/frontend/server/intelligent_development_runs/runner.py @@ -0,0 +1,1342 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Recover the full build lifecycle using Codex and Sandbox execution facts.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from collections.abc import Awaitable, Callable +from dataclasses import asdict, replace +from datetime import datetime, timezone +from typing import Any + +from frontend.server.intelligent_development import DeliveryReference, StudioCredentials +from frontend.server.intelligent_development_projects import ( + IntelligentDevelopmentProjectNotFound, + IntelligentDevelopmentProjectService, + IntelligentDevelopmentProjectStorageUnavailable, + IntelligentDevelopmentVersionIntegrityError, +) +from frontend.server.intelligent_development_task import ( + BUILD_RESULT_TOOL, + COMPLETION_FILE_PREFIX, + CompletionContract, + CredentialResolver, + DeliveryPublisher, + IntentDecision, + TaskCredentialLease, + builder_prompt, + create_credential_lease, + invalidate_current_delivery, + parse_build_result, + read_completion_contract, + remove_completion_file, + result_reporting_prompt, +) +from frontend.server.sandbox_remote import SandboxRemoteTransport +from veadk.cli.codex_app_server import ( + CodexAppServerEvent, + CodexAppServerRequestError, + CodexAppServerSession, + CodexPermissionSettings, +) +from veadk.cli.frontend_sandbox import ( + SandboxCloudSession, + SandboxSessionNotFoundError, + redact_sandbox_text, +) +from veadk.utils.logger import get_logger + +from .models import Run +from .output import TaskTextProjection +from .repository import RunCapacity, RunConflict, RunLeaseLost, RunRepository +from .shell import RunShell + +logger = get_logger(__name__) +_PERMISSIONS = CodexPermissionSettings( + approval_policy="never", + approvals_reviewer="auto_review", + sandbox_mode="danger-full-access", + network_access=True, +) +_REPORT_PERMISSIONS = CodexPermissionSettings( + approval_policy="never", + approvals_reviewer="auto_review", + sandbox_mode="read-only", + network_access=False, +) + + +class StopRequested(Exception): + pass + + +class InputUnconfirmed(Exception): + pass + + +class NativeTurnFailed(Exception): + """An authoritative terminal turn needs corrected input or configuration.""" + + +def _public_issue(run: Run, error: Exception) -> dict[str, Any]: + code, message, retryable = ( + "INTELLIGENT_DEVELOPMENT_FAILED", + "任务暂时无法恢复,请稍后继续。", + True, + ) + if isinstance(error, InputUnconfirmed): + code, message = ( + "INTELLIGENT_DEVELOPMENT_INPUT_UNCONFIRMED", + "消息是否送达暂时无法确认,请稍后恢复以核对状态。", + ) + elif isinstance(error, NativeTurnFailed): + code, message = ( + "SANDBOX_INVOCATION_FAILED", + "Codex 本轮未完成,请检查模型配置或补充要求后继续。", + ) + elif isinstance(error, IntelligentDevelopmentVersionIntegrityError): + code, message, retryable = ( + "INTELLIGENT_DEVELOPMENT_VERSION_INVALID", + "项目版本源码完整性校验失败。请重新构建。", + False, + ) + elif isinstance(error, IntelligentDevelopmentProjectStorageUnavailable): + code, message = ( + "INTELLIGENT_DEVELOPMENT_STORAGE_UNAVAILABLE", + "源码已生成,但项目版本暂时无法保存。请稍后继续保存。", + ) + elif run.phase == "outcome_read": + code, message = ( + "INTELLIGENT_DEVELOPMENT_OUTCOME_INVALID", + "Codex 未能确认本轮结果,未发布新版本。请继续以补齐验证结果。", + ) + elif run.phase == "cycle_complete": + code, message = ( + "INTELLIGENT_DEVELOPMENT_CLEANUP_INCOMPLETE", + "任务清理暂未完成,请稍后继续。", + ) + elif run.phase == "delivery": + message = "源码交付暂未完成,请稍后继续。系统会核对已有结果。" + elif run.phase == "prepare": + message = "开发环境准备暂未完成,请检查配置或稍后继续。" + return { + "code": code, + "message": message, + "retryable": retryable, + "phase": run.phase, + } + + +class DevelopmentRunner: + def __init__( + self, + repository: RunRepository, + *, + resolve_session: Callable[[str, str], Awaitable[SandboxCloudSession]], + workspace: Callable[[SandboxCloudSession], str], + credentials: CredentialResolver, + render_event: Callable[ + [CodexAppServerEvent, TaskCredentialLease], + tuple[str, dict[str, Any]] | None, + ], + renew_session: Callable[[SandboxCloudSession], Awaitable[SandboxCloudSession]] + | None = None, + project_service: IntelligentDevelopmentProjectService | None = None, + validation_region: str = "cn-beijing", + validation_project: str = "default", + codex_factory: Callable[[str], CodexAppServerSession] = CodexAppServerSession, + command_progress: Callable[[CodexAppServerEvent], str | None] = lambda _: None, + ) -> None: + self.repository = repository + self.resolve_session = resolve_session + self.workspace = workspace + self.credentials = credentials + self.render_event = render_event + self.renew_session = renew_session + self.project_service = project_service + self.validation_region = validation_region + self.validation_project = validation_project + self.codex_factory = codex_factory + self.command_progress = command_progress + self.retry_delays = (1, 2, 4, 8, 16) + + async def __call__(self, initial: Run, token: str) -> None: + owner, run_id = initial.owner_id, initial.id + for attempt in range(len(self.retry_delays) + 1): + run = await self.repository.get(owner, run_id) + if run.terminal: + return + try: + await self._attempt(run, token) + return + except asyncio.CancelledError: + # Local shutdown detaches observation; it never requests interruption. + raise + except RunLeaseLost: + raise + except RunCapacity: + await self.repository.update( + owner, + run_id, + token, + state="waiting_user", + state_message="任务输出已达到短期存储上限,已有内容已保留。可停止任务后发起新任务。", + ) + return + except SandboxSessionNotFoundError: + await self.repository.update( + owner, + run_id, + token, + state="failed", + state_message="开发环境已失效,已有输出已保留。请创建新的开发环境。", + ) + return + except Exception as error: + run = await self.repository.get(owner, run_id) + logger.warning( + "Development recovery run_id=%s session_id=%s thread_id=%s phase=%s attempt=%s error_type=%s", + run_id, + run.session_id, + run.thread_id, + run.phase, + attempt + 1, + type(error).__name__, + ) + if run.terminal: + return + issue = _public_issue(run, error) + await self.repository.checkpoint(owner, run_id, token, issue=issue) + if not issue["retryable"] and not run.stop_requested: + await self.repository.update( + owner, + run_id, + token, + state="failed", + state_message=issue["message"] + "已有输出已保留。", + ) + return + if attempt == len(self.retry_delays) or isinstance( + error, NativeTurnFailed + ): + state = "stopping" if run.stop_requested else "waiting_user" + message = ( + "已请求停止,正在等待开发环境恢复以确认停止状态。" + if run.stop_requested + else issue["message"] + "已有输出已保留。" + ) + else: + state = "stopping" if run.stop_requested else "recovering" + message = ( + "正在确认停止状态。" + if run.stop_requested + else "正在重新连接并恢复任务。" + ) + await self.repository.update( + owner, run_id, token, state=state, state_message=message + ) + if isinstance(error, NativeTurnFailed): + return + if attempt < len(self.retry_delays): + await asyncio.sleep(self.retry_delays[attempt]) + + async def _attempt(self, run: Run, token: str) -> None: + owner, run_id = run.owner_id, run.id + await self.repository.heartbeat(owner, run_id, token) + cloud = await self.resolve_session(run.session_id, owner) + if cloud.created_by != owner: + raise SandboxSessionNotFoundError("开发环境不属于当前用户。") + if self.renew_session is not None and not run.stop_requested: + cloud = await self.renew_session(cloud) + root = self.workspace(cloud) + transport = SandboxRemoteTransport(cloud.endpoint) + task_root = f"/home/gem/.intelligent-development/tasks/{run_id}" + lease = TaskCredentialLease( + transport, + task_root, + f"{task_root}/with-agentkit-credentials", + f"{task_root}/credentials.json", + self.credentials().secret_values, + ) + if run.checkpoint.get("lease_ready") and run.phase in { + "coding", + "reporting", + "outcome_read", + "delivery", + "version", + }: + # A credential rotation must not make old in-flight output unredactable. + # Read the existing lease only into memory; secrets never enter SQLite. + value = json.loads( + await transport.download(lease.credential_path, max_bytes=16_384) + ) + previous = StudioCredentials( + value["accessKeyId"], + value["secretAccessKey"], + value.get("sessionToken", ""), + ) + lease.exact_secrets = tuple( + set((*lease.exact_secrets, *previous.secret_values)) + ) + codex = self.codex_factory(cloud.endpoint) + codex.cwd = root + codex.permissions = _PERMISSIONS + if run.checkpoint.get("result_protocol") == "tool-v1": + codex.dynamic_tools = (BUILD_RESULT_TOOL,) + + async def submit_result(params): + return await self._submit_result(owner, run_id, token, params, lease) + + codex.dynamic_tool_handler = submit_result + # Bind before connect, so restart cannot select an unrelated "latest" thread. + controller: asyncio.Task[None] | None = None + work: asyncio.Task[None] | None = None + try: + if run.thread_id: + inputs = await self.repository.inputs(owner, run_id) + history = await self.repository.session_runs(owner, run.session_id) + unused = ( + run.phase == "prepare" + and not run.turn_id + and not run.checkpoint.get("continuation_sending") + and all(item["status"] == "pending" for item in inputs) + and not any(item.id != run_id for item in history) + ) + # Codex does not persist a rollout before the first accepted turn. + # Replacing that empty preparation is safe only before submission. + await codex.attach_thread(run.thread_id, allow_empty_restart=unused) + else: + await codex.connect() + await self.repository.update( + owner, run_id, token, thread_id=codex.thread_id + ) + if not run.stop_requested: + await self.repository.append_event( + owner, + run_id, + token, + "progress", + {"text": "Codex 正在处理本次请求。"}, + ) + controller = asyncio.create_task(self._control(run, token, codex)) + + async def drive(): + nonlocal run, lease + while True: + run = await self.repository.get(owner, run_id) + if run.stop_requested: + await self._confirm_stop(run, token, codex) + raise StopRequested + if not run.checkpoint.get("lease_ready"): + lease = await create_credential_lease( + cloud.endpoint, self.credentials, lease_id=run_id + ) + run = await self.repository.checkpoint( + owner, run_id, token, lease_ready=True + ) + if run.phase in {"prepare", "coding", "reporting"}: + await self._coding(run, token, codex, lease, cloud, root) + run = await self.repository.get(owner, run_id) + await self._check_stop(run) + if run.phase == "outcome_read": + tool_protocol = ( + run.checkpoint.get("result_protocol") == "tool-v1" + ) + if tool_protocol: + sending = await self.repository.inputs( + owner, run_id, statuses=("sending",) + ) + await self._reconcile_inputs(run, token, codex, sending) + run = await self.repository.get(owner, run_id) + if await self.repository.inputs( + owner, run_id, statuses=("pending",) + ): + await self._next_input(run, token) + continue + try: + if tool_protocol: + result = await self.repository.result_for_turn( + owner, run_id, token + ) + if result is None: + raise ValueError( + "No current build result submitted" + ) + completion = CompletionContract(**result) + else: + completion = await read_completion_contract( + transport, str(run.checkpoint["completion_path"]) + ) + if not completion.answered and ( + not completion.intent_summary + or not completion.acceptance_criteria + ): + raise ValueError("Incomplete delivery context") + except (ValueError, FileNotFoundError): + if await self._continue_turn( + run, + token, + report_only=tool_protocol, + force=bool(run.checkpoint.get("manual_resume")), + ): + continue + raise + + def public(text: str) -> str: + for secret in lease.exact_secrets: + if secret: + text = text.replace(secret, "***") + return redact_sandbox_text(text) + + completion = replace( + completion, + summary=public(completion.summary), + intent_summary=public(completion.intent_summary), + acceptance_criteria=tuple( + public(item) for item in completion.acceptance_criteria + ), + ) + await self.repository.checkpoint( + owner, + run_id, + token, + completion=asdict(completion), + completion_revision=int( + run.checkpoint.get("accepted_revision", 1) + ), + ) + await self.repository.update( + owner, + run_id, + token, + phase="delivery" + if not completion.answered + else "cycle_complete", + state_message="正在整理产物" + if not completion.answered + else "正在完成请求", + ) + run = await self.repository.get(owner, run_id) + await self._check_stop(run) + if run.phase in {"delivery", "version"}: + if run.checkpoint.get( + "result_protocol" + ) == "tool-v1" and not await self.repository.revision_current( + owner, + run_id, + token, + int(run.checkpoint["completion_revision"]), + ): + await self._next_input(run, token) + continue + await self._deliver(run, token, transport, lease, root) + run = await self.repository.get(owner, run_id) + await self._check_stop(run) + if run.phase == "cycle_complete": + sending = await self.repository.inputs( + owner, run_id, statuses=("sending",) + ) + if sending: + await self._reconcile_inputs(run, token, codex, sending) + # Input may arrive during cleanup. finish() atomically detects it. + if run.checkpoint.get("completion_path"): + await remove_completion_file( + transport, str(run.checkpoint["completion_path"]) + ) + await lease.cleanup() + logger.info( + "Development lifecycle run_id=%s session_id=%s thread_id=%s reason=task_cleanup_completed", + run_id, + run.session_id, + run.thread_id, + ) + await self.repository.checkpoint( + owner, run_id, token, lease_ready=False + ) + run = await self.repository.get(owner, run_id) + await self._check_stop(run) + revision = int(run.checkpoint.get("accepted_revision", 1)) + if ( + run.checkpoint.get("result_protocol") != "tool-v1" + and run.checkpoint.get("version") + and not run.checkpoint.get("delivery_emitted") + ): + delivery_view = run.checkpoint["version"] + await self.repository.append_event( + owner, + run_id, + token, + "development.succeeded" + if delivery_view.get("verified") + else "development.source_ready", + { + "payload": {"delivery": delivery_view}, + "inputRevision": revision, + }, + ) + await self.repository.checkpoint( + owner, run_id, token, delivery_emitted=True + ) + if await self.repository.finish(owner, run_id, token, revision): + return + run = await self.repository.get(owner, run_id) + await self._check_stop(run) + await self._next_input(run, token) + + work = asyncio.create_task(drive()) + done, _ = await asyncio.wait( + {work, controller}, return_when=asyncio.FIRST_COMPLETED + ) + if controller in done: + await controller + raise RuntimeError("Task controller stopped unexpectedly") + await work + except StopRequested: + run = await self.repository.get(owner, run_id) + await self._confirm_stop(run, token, codex) + if run.checkpoint.get("completion_path"): + await remove_completion_file( + transport, str(run.checkpoint["completion_path"]) + ) + await lease.cleanup() + await self.repository.update( + owner, + run_id, + token, + state="cancelled", + phase="complete", + state_message="任务已停止,已有输出和修改已保留。", + ) + finally: + tasks = [task for task in (controller, work) if task is not None] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + await codex.close() + + @staticmethod + async def _check_stop(run: Run) -> None: + if run.stop_requested: + raise StopRequested + + async def _submit_result( + self, + owner: str, + run_id: str, + token: str, + params: dict[str, Any], + lease: TaskCredentialLease, + ) -> dict[str, Any]: + try: + arguments = params.get("arguments") + encoded = json.dumps(arguments, sort_keys=True, allow_nan=False).encode() + if len(encoded) > 65_536: + raise ValueError("Result exceeds the size limit.") + revision, completion = parse_build_result(arguments) + # turn/start and replayed requests can arrive before the stream + # consumer commits its turn association. Never derive it from a tool. + for _ in range(40): + run = await self.repository.get(owner, run_id) + if run.turn_id or run.stop_requested or run.terminal: + break + await asyncio.sleep(0.05) + + def public(value: str) -> str: + for secret in lease.exact_secrets: + if secret: + value = value.replace(secret, "***") + return redact_sandbox_text(value) + + completion = replace( + completion, + summary=public(completion.summary), + intent_summary=public(completion.intent_summary), + acceptance_criteria=tuple( + public(item) for item in completion.acceptance_criteria + ), + ) + response = { + "success": True, + "contentItems": [ + { + "type": "inputText", + "text": f"Result saved for input revision {revision}. Finish this turn with your user-facing summary. Do not modify deliverable files after submitting. If new requirements arrive, apply them and resubmit.", + } + ], + } + return await self.repository.submit_result( + owner, + run_id, + token, + thread_id=params["threadId"], + turn_id=params["turnId"], + call_id=params["callId"], + revision=revision, + digest=hashlib.sha256(encoded).hexdigest(), + completion=asdict(completion), + response=response, + ) + except (ValueError, RunConflict) as error: + return { + "success": False, + "contentItems": [{"type": "inputText", "text": str(error)}], + } + + async def _next_input(self, run: Run, token: str) -> None: + await self.repository.update( + run.owner_id, + run.id, + token, + checkpoint_updates={ + "completion": None, + "completion_revision": None, + "delivery": None, + "version": None, + "completion_path": "", + "publish_time": "", + "shell_root": "", + "delivery_emitted": False, + "continuation": None, + "continuation_sending": False, + "continuation_count": 0, + "report_only": False, + "report_count": 0, + "manual_resume": False, + }, + phase="prepare", + turn_id="", + state_message="正在处理最新要求", + ) + + async def _coding( + self, + run: Run, + token: str, + codex: CodexAppServerSession, + lease: TaskCredentialLease, + cloud: SandboxCloudSession, + root: str, + ) -> None: + owner, run_id = run.owner_id, run.id + inputs = await self.repository.inputs(owner, run_id) + resume_id = run.turn_id if run.phase in {"coding", "reporting"} else "" + if resume_id and run.checkpoint.get("manual_resume"): + turn = await codex.read_turn(resume_id) + if turn and turn.get("status") in {"failed", "interrupted"}: + await self.repository.checkpoint( + owner, run_id, token, manual_resume=False + ) + pending = await self.repository.inputs( + owner, run_id, statuses=("pending",) + ) + if pending: + await self._next_input(run, token) + else: + await self._continue_turn( + run, + token, + force=True, + report_only=bool(run.checkpoint.get("report_only")), + ) + return + continuation = run.checkpoint.get("continuation") + report_only = bool(run.checkpoint.get("report_only")) + tool_protocol = run.checkpoint.get("result_protocol") == "tool-v1" + current = ( + None + if resume_id or continuation + else next( + (item for item in inputs if item["status"] in {"sending", "pending"}), + None, + ) + ) + if not resume_id and current is not None and current["status"] == "sending": + found = await codex.find_input_turn(current["client_id"]) + if found is None: + raise InputUnconfirmed + resume_id = str(found["id"]) + if continuation and not resume_id: + current = None + if run.checkpoint.get("continuation_sending"): + found = await codex.find_input_turn(str(continuation)) + if found is None: + raise InputUnconfirmed + resume_id = str(found["id"]) + if not resume_id and current is None and not continuation: + raise InputUnconfirmed + revision = ( + current["revision"] + if current + else int(run.checkpoint.get("accepted_revision", 1)) + ) + completion_path = ( + "" + if tool_protocol + else str( + run.checkpoint.get("completion_path") + or f"{root}/{COMPLETION_FILE_PREFIX}{run_id}-{revision}.json" + ) + ) + await self.repository.checkpoint( + owner, run_id, token, completion_path=completion_path + ) + await self.repository.update( + owner, + run_id, + token, + state="running", + phase="reporting" if report_only else "coding", + state_message="正在补齐交付信息" if report_only else "正在处理请求", + ) + prompt = "" + client_id = "" + text_projection = TaskTextProjection(lease.exact_secrets) + thinking_projection = TaskTextProjection(lease.exact_secrets) + tool_projection = TaskTextProjection(lease.exact_secrets) + if not resume_id: + if continuation: + client_id = str(continuation) + await self.repository.checkpoint( + owner, run_id, token, continuation_sending=True + ) + message = ( + "继续完成当前对话中尚未完成的要求。先检查工作区、已有验证结果和远端资源," + "避免重复已成功的操作;完成后写入本轮结果文件。" + ) + else: + assert current is not None + client_id = current["client_id"] + await self.repository.input_status( + owner, run_id, token, client_id, "sending" + ) + message = current["message"] + prompt = ( + result_reporting_prompt( + revision, + [ + item["message"] + for item in inputs + if item["status"] == "delivered" + ], + await self._project_context(run), + ) + if report_only + else builder_prompt( + message, + launcher_path=lease.launcher_path, + completion_path=completion_path, + expire_at=cloud.expire_at, + remaining_lifetime_minutes=max( + 1, + int( + ( + datetime.fromisoformat( + cloud.expire_at.replace("Z", "+00:00") + ) + - datetime.now(timezone.utc) + ).total_seconds() + / 60 + ), + ) + if cloud.expire_at + else 480, + validation_region=self.validation_region, + validation_project=self.validation_project, + project_context=await self._project_context(run), + input_revision=revision if tool_protocol else None, + ) + ) + try: + async for event in codex.stream_turn( + prompt, + permissions=_REPORT_PERMISSIONS if report_only else _PERMISSIONS, + timeout_seconds=120 if report_only else 3300, + client_user_message_id=client_id, + resume_turn_id=resume_id, + interrupt_on_cancel=False, + output_schema=None, + ): + if event.kind == "thread_ready": + await self._check_stop(await self.repository.get(owner, run_id)) + await self.repository.update( + owner, run_id, token, thread_id=codex.thread_id + ) + elif event.kind == "turn_started": + await self.repository.update( + owner, + run_id, + token, + thread_id=codex.thread_id, + turn_id=event.turn_id, + ) + if current is not None: + await self.repository.input_status( + owner, + run_id, + token, + current["client_id"], + "delivered", + turn_id=event.turn_id, + ) + await self.repository.checkpoint( + owner, run_id, token, accepted_revision=revision + ) + await self.repository.record_turn( + owner, + run_id, + token, + thread_id=codex.thread_id, + turn_id=event.turn_id, + revision=revision, + status="inProgress", + metrics={ + **( + event.response + if isinstance(event.response, dict) + else {} + ), + "resumed": bool(resume_id), + }, + ) + elif event.kind in {"turn_completed", "usage"}: + metrics = event.response if isinstance(event.response, dict) else {} + if event.usage is not None: + metrics = { + **metrics, + "usage": event.usage.public_dict(), + "threadTotal": event.thread_total.public_dict() + if event.thread_total + else None, + } + await self.repository.record_turn( + owner, + run_id, + token, + thread_id=codex.thread_id, + turn_id=event.turn_id or codex.active_turn_id, + revision=revision, + status=event.status + if event.kind == "turn_completed" + else "inProgress", + metrics=metrics, + ) + if event.kind == "usage": + projected = self.render_event(event, lease) + if projected is not None: + kind, payload = projected + await self.repository.append_event( + owner, + run_id, + token, + kind, + {**payload, "turnId": event.turn_id}, + ) + elif event.kind == "user_input": + response = ( + event.response if isinstance(event.response, dict) else {} + ) + client = response.get("clientId") + match = next( + ( + item + for item in await self.repository.inputs(owner, run_id) + if item["client_id"] == client + ), + None, + ) + if match: + await self.repository.input_status( + owner, + run_id, + token, + match["client_id"], + "delivered", + turn_id=event.turn_id, + ) + await self.repository.checkpoint( + owner, run_id, token, accepted_revision=match["revision"] + ) + else: + if event.kind in { + "text", + "text_snapshot", + "assistant_final", + "commentary", + }: + event = text_projection.apply( + event, event.turn_id or codex.active_turn_id + ) + elif event.kind == "thinking": + event = thinking_projection.apply( + event, + event.turn_id or codex.active_turn_id, + snapshot_only=True, + ) + elif event.kind == "tool_output": + event = tool_projection.apply( + event, event.turn_id or codex.active_turn_id + ) + projected = self.render_event(event, lease) + progress = self.command_progress(event) + if progress: + await self.repository.append_event( + owner, run_id, token, "progress", {"text": progress} + ) + if projected is not None: + kind, payload = projected + payload["turnId"] = event.turn_id or codex.active_turn_id + await self.repository.append_event( + owner, run_id, token, kind, payload + ) + except (RunLeaseLost, RunCapacity): + raise + except Exception: + latest = await self.repository.get(owner, run_id) + if latest.stop_requested: + await self._confirm_stop(latest, token, codex) + raise StopRequested + if latest.turn_id: + turn = await codex.read_turn(latest.turn_id) + if turn and turn.get("status") in {"failed", "interrupted"}: + await self.repository.record_turn( + owner, + run_id, + token, + thread_id=codex.thread_id, + turn_id=latest.turn_id, + revision=revision, + status=str(turn["status"]), + metrics=turn, + ) + failure = turn.get("error") or {} + info = ( + failure.get("codexErrorInfo") + if isinstance(failure, dict) + else None + ) + code = next(iter(info), "") if isinstance(info, dict) else info + transient = turn.get("status") == "interrupted" or code in { + "serverOverloaded", + "rateLimitExceeded", + "internalServerError", + "httpConnectionFailed", + "responseStreamConnectionFailed", + "responseStreamDisconnected", + "responseTooManyFailedAttempts", + } + if transient: + sending = await self.repository.inputs( + owner, run_id, statuses=("sending",) + ) + await self._reconcile_inputs(latest, token, codex, sending) + if await self._continue_turn( + latest, token, report_only=report_only + ): + return + raise NativeTurnFailed from None + raise + latest = await self.repository.get(owner, run_id) + await self._check_stop(latest) + for event in text_projection.finish(latest.turn_id): + projected = self.render_event(event, lease) + if projected is not None: + kind, payload = projected + await self.repository.append_event( + owner, run_id, token, kind, {**payload, "turnId": latest.turn_id} + ) + await self.repository.record_turn( + owner, + run_id, + token, + thread_id=codex.thread_id, + turn_id=latest.turn_id, + revision=int(latest.checkpoint.get("accepted_revision", revision)), + status="completed", + ) + await self.repository.checkpoint( + owner, run_id, token, continuation=None, continuation_sending=False + ) + await self.repository.update( + owner, run_id, token, phase="outcome_read", state_message="正在整理产物" + ) + + async def _continue_turn( + self, run: Run, token: str, *, force: bool = False, report_only: bool = False + ) -> bool: + counter = "report_count" if report_only else "continuation_count" + count = int(run.checkpoint.get(counter, 0)) + if count >= (1 if report_only else 2) and not force: + return False + # Only called after an authoritative terminal turn, never after an + # ambiguous start. Continuations inspect existing effects in the same thread. + await self.repository.update( + run.owner_id, + run.id, + token, + checkpoint_updates={ + "continuation": f"{run.id}-{'report' if report_only else 'recover'}-{run.input_revision}-{count + 1}", + counter: count + 1, + "report_only": report_only, + "report_deadline": self.repository.clock() + 120 + if report_only + else None, + "continuation_sending": False, + "manual_resume": False, + }, + phase="reporting" if report_only else "prepare", + turn_id="", + state="recovering", + state_message="正在补齐交付信息" + if report_only + else "Codex 本轮执行已结束,正在继续未完成的工作。", + ) + return True + + async def _project_context(self, run: Run) -> str: + if self.project_service is None: + return "" + import json + + try: + await self.project_service.get_binding(run.owner_id, run.session_id) + except IntelligentDevelopmentProjectNotFound: + await self.project_service.create_binding( + owner_id=run.owner_id, + session_id=run.session_id, + display_name=run.message[:80], + ) + base = await self.project_service.base_metadata(run.owner_id, run.session_id) + if base is None: + return "" + return json.dumps( + { + "intentSummary": base.intent_summary, + "acceptanceCriteria": base.acceptance_criteria, + "agentName": base.agent_name, + "entryPoint": base.entry_point, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + + async def _deliver( + self, + run: Run, + token: str, + transport: SandboxRemoteTransport, + lease: TaskCredentialLease, + root: str, + ) -> None: + owner, run_id = run.owner_id, run.id + completion = CompletionContract(**run.checkpoint["completion"]) + revision = int(run.checkpoint.get("accepted_revision", 1)) + delivery_id = hashlib.sha256(f"{run_id}:{revision}".encode()).hexdigest()[:32] + publish_time = str( + run.checkpoint.get("publish_time") or datetime.now(timezone.utc).isoformat() + ) + if run.phase == "delivery": + shell_root = f"{lease.root}/command-{delivery_id}" + await self.repository.checkpoint( + owner, run_id, token, publish_time=publish_time, shell_root=shell_root + ) + shell = RunShell(self.repository, run, token, transport, shell_root) + metadata = None + if self.project_service is not None: + base = await self.project_service.base_metadata(owner, run.session_id) + if base is not None: + metadata = (base.agent_name, base.entry_point) + await invalidate_current_delivery(transport) + delivery = await DeliveryPublisher(transport).publish( + session_id=run.session_id, + project_root=root, + task_root=lease.root, + completion=completion, + exact_secrets=lease.exact_secrets, + acceptance_criteria=tuple(completion.acceptance_criteria), + trusted_manifest_metadata=metadata, + delivery_id=delivery_id, + validated_at=publish_time, + execute_command=shell.execute, + ) + await self.repository.checkpoint( + owner, run_id, token, delivery=asdict(delivery) + ) + if run.checkpoint.get("result_protocol") != "tool-v1": + await self.repository.append_event( + owner, + run_id, + token, + "development.source_ready", + { + "payload": { + "delivery": {**delivery.as_dict(), "verified": False} + }, + "inputRevision": revision, + }, + ) + await self.repository.update( + owner, + run_id, + token, + phase="version", + state_message="正在保存版本", + ) + else: + delivery = DeliveryReference(**run.checkpoint["delivery"]) + run = await self.repository.get(owner, run_id) + await self._check_stop(run) + if run.checkpoint.get( + "result_protocol" + ) == "tool-v1" and not await self.repository.revision_current( + owner, run_id, token, revision + ): + await self._next_input(run, token) + return + public = delivery.as_dict() + if self.project_service is not None: + decision = IntentDecision( + "accept", + "", + completion.intent_summary, + tuple(completion.acceptance_criteria), + True, + ) + _, version = await self.project_service.persist_delivery( + owner_id=owner, + session_id=run.session_id, + transport=transport, + delivery=delivery, + decision=decision, + version_id=delivery_id, + created_at=datetime.fromisoformat(publish_time), + ) + public.update( + { + "projectId": version.project_id, + "versionId": version.version_id, + "parentVersionId": version.parent_version_id, + } + ) + await self.repository.checkpoint(owner, run_id, token, version=public) + await self._check_stop(await self.repository.get(owner, run_id)) + await self.repository.checkpoint(owner, run_id, token, version=public) + await self.repository.update( + owner, run_id, token, phase="cycle_complete", state_message="正在完成请求" + ) + + async def _control( + self, initial: Run, token: str, codex: CodexAppServerSession + ) -> None: + owner, run_id = initial.owner_id, initial.id + renew_at = 0.0 + while True: + await asyncio.sleep(0.3) + run = await self.repository.get(owner, run_id) + if run.terminal: + return + try: + await self.repository.heartbeat(owner, run_id, token) + if ( + self.repository.clock() - run.created_at + >= self.repository.max_active_seconds + ): + run = await self.repository.request_stop(owner, run_id) + if run.stop_requested: + if run.turn_id and codex.active: + await codex.interrupt_turn(run.turn_id) + await asyncio.sleep(1) + continue + if run.phase == "reporting": + if ( + run.turn_id + and codex.active + and self.repository.clock() + >= float(run.checkpoint.get("report_deadline") or 0) + ): + await codex.interrupt_turn(run.turn_id) + continue + if run.phase != "coding" or not run.turn_id: + continue + if asyncio.get_running_loop().time() >= renew_at: + cloud = await self.resolve_session(run.session_id, owner) + if self.renew_session is not None: + cloud = await self.renew_session(cloud) + codex.refresh_endpoint(cloud.endpoint) + renew_at = asyncio.get_running_loop().time() + 60 + sending = await self.repository.inputs( + owner, run_id, statuses=("sending",) + ) + if sending: + await self._reconcile_inputs(run, token, codex, sending) + continue + pending = await self.repository.inputs( + owner, run_id, statuses=("pending",) + ) + if not pending or run.checkpoint.get("steer_wait_turn") == run.turn_id: + continue + item = pending[0] + await self.repository.input_status( + owner, + run_id, + token, + item["client_id"], + "sending", + turn_id=run.turn_id, + ) + try: + await codex.steer_turn( + ( + f"[Studio inputRevision={item['revision']}; resubmit submit_build_result after applying these requirements]\n{item['message']}" + if run.checkpoint.get("result_protocol") == "tool-v1" + else item["message"] + ), + run.turn_id, + item["client_id"], + ) + except Exception as error: + # A definite precondition rejection can safely become the next turn. + if ( + isinstance(error, CodexAppServerRequestError) + and error.code == -32600 + ): + await self.repository.input_status( + owner, run_id, token, item["client_id"], "pending" + ) + await self.repository.checkpoint( + owner, run_id, token, steer_wait_turn=run.turn_id + ) + raise + await self.repository.input_status( + owner, + run_id, + token, + item["client_id"], + "delivered", + turn_id=run.turn_id, + ) + await self.repository.checkpoint( + owner, run_id, token, accepted_revision=item["revision"] + ) + except (RunLeaseLost, asyncio.CancelledError): + raise + except Exception as error: + logger.info( + "Development control pending run_id=%s error_type=%s", + run_id, + type(error).__name__, + ) + await asyncio.sleep(2) + + async def _reconcile_inputs( + self, + run: Run, + token: str, + codex: CodexAppServerSession, + inputs: list[dict[str, Any]], + ) -> None: + for item in inputs: + found = await codex.find_input_turn(item["client_id"]) + if found is None: + raise InputUnconfirmed + await self.repository.input_status( + run.owner_id, + run.id, + token, + item["client_id"], + "delivered", + turn_id=str(found["id"]), + ) + await self.repository.checkpoint( + run.owner_id, run.id, token, accepted_revision=item["revision"] + ) + + async def _confirm_stop( + self, run: Run, token: str, codex: CodexAppServerSession + ) -> None: + shell_root = run.checkpoint.get("shell_root") + if ( + shell_root + and run.phase == "delivery" + and run.checkpoint.get(f"{str(shell_root).rsplit('/', 1)[-1]}_submitted") + ): + cloud = await self.resolve_session(run.session_id, run.owner_id) + shell = RunShell( + self.repository, + run, + token, + SandboxRemoteTransport(cloud.endpoint), + str(shell_root), + ) + if not await shell.confirm_stopped(): + raise RuntimeError("Delivery command stop is not yet confirmed") + turn_id = run.turn_id + if not turn_id and run.checkpoint.get("continuation_sending"): + found = await codex.find_input_turn(str(run.checkpoint["continuation"])) + if found is None: + raise RuntimeError( + "Cannot confirm whether the continuation was accepted" + ) + turn_id = str(found["id"]) + if not turn_id: + sending = await self.repository.inputs( + run.owner_id, run.id, statuses=("sending",) + ) + if sending: + found = await codex.find_input_turn(sending[0]["client_id"]) + if found is None: + raise RuntimeError( + "Cannot confirm whether the initial turn was accepted" + ) + turn_id = str(found["id"]) + if turn_id: + turn = await codex.read_turn(turn_id) + if turn is None: + raise RuntimeError("Cannot confirm the remote turn state") + if turn.get("status") == "inProgress": + await codex.interrupt_turn(turn_id) + for _ in range(20): + await asyncio.sleep(0.5) + turn = await codex.read_turn(turn_id) + if turn is not None and turn.get("status") in { + "completed", + "failed", + "interrupted", + }: + await self.repository.record_turn( + run.owner_id, + run.id, + token, + thread_id=codex.thread_id, + turn_id=turn_id, + revision=run.input_revision, + status=str(turn["status"]), + metrics=turn, + ) + return + raise RuntimeError("Remote interruption is not confirmed") + if turn.get("status") in {"completed", "failed", "interrupted"}: + await self.repository.record_turn( + run.owner_id, + run.id, + token, + thread_id=codex.thread_id, + turn_id=turn_id, + revision=run.input_revision, + status=str(turn["status"]), + metrics=turn, + ) diff --git a/frontend/server/intelligent_development_runs/service.py b/frontend/server/intelligent_development_runs/service.py new file mode 100644 index 000000000..8b2775a46 --- /dev/null +++ b/frontend/server/intelligent_development_runs/service.py @@ -0,0 +1,172 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Own task workers independently of HTTP subscribers.""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any + +from veadk.utils.logger import get_logger + +from .models import Run +from .repository import RunLeaseLost, RunRepository + +logger = get_logger(__name__) + + +class RunService: + def __init__( + self, repository: RunRepository, execute: Callable[[Run, str], Awaitable[None]] + ) -> None: + self.repository = repository + self.execute = execute + self._workers: dict[tuple[str, str], asyncio.Task[None]] = {} + self._scheduler: asyncio.Task[None] | None = None + self._closing = False + self.poll_interval = 0.25 + self.heartbeat_seconds = 10.0 + + async def start(self) -> None: + self._closing = False + if self._scheduler is None or self._scheduler.done(): + self._scheduler = asyncio.create_task( + self._schedule(), name="development-run-scheduler" + ) + + async def _schedule(self) -> None: + while not self._closing: + try: + for run in await self.repository.recoverable(): + if ( + self.repository.clock() - run.created_at + >= self.repository.max_active_seconds + ): + run = await self.repository.request_stop(run.owner_id, run.id) + self.launch(run) + await self.repository.cleanup() + except Exception as error: + logger.error( + "Development scheduler failed error_type=%s", type(error).__name__ + ) + await asyncio.sleep(2) + + def launch(self, run: Run) -> None: + key = (run.owner_id, run.id) + if self._closing or run.terminal: + return + if sum(not worker.done() for worker in self._workers.values()) >= 16: + return + previous = self._workers.get(key) + if previous is not None and not previous.done(): + return + self._workers[key] = asyncio.create_task( + self._work(run), name=f"development-run-{run.id}" + ) + + async def _work(self, run: Run) -> None: + token = await self.repository.claim(run.owner_id, run.id) + if token is None: + self._workers.pop((run.owner_id, run.id), None) + return + work = asyncio.ensure_future(self.execute(run, token)) + heartbeat = asyncio.create_task(self._heartbeat(run, token)) + try: + done, _ = await asyncio.wait( + {work, heartbeat}, return_when=asyncio.FIRST_COMPLETED + ) + if heartbeat in done: + # A lost lease must stop this worker before it dispatches more RPCs. + await heartbeat + await work + except RunLeaseLost: + logger.warning("Development executor lease lost run_id=%s", run.id) + except asyncio.CancelledError: + raise + except Exception as error: + logger.error( + "Development worker failed run_id=%s error_type=%s", + run.id, + type(error).__name__, + ) + with contextlib.suppress(RunLeaseLost): + current = await self.repository.get(run.owner_id, run.id) + if not current.terminal: + await self.repository.update( + run.owner_id, + run.id, + token, + state="stopping" if current.stop_requested else "waiting_user", + state_message="任务状态暂时无法确认,已保留输出,请稍后恢复。", + ) + finally: + for task in (work, heartbeat): + task.cancel() + await asyncio.gather(work, heartbeat, return_exceptions=True) + await self.repository.release(run.owner_id, run.id, token) + self._workers.pop((run.owner_id, run.id), None) + + async def _heartbeat(self, run: Run, token: str) -> None: + while True: + await asyncio.sleep(5) + await self.repository.heartbeat(run.owner_id, run.id, token) + + async def stop(self, owner: str, run_id: str) -> Run: + run = await self.repository.request_stop(owner, run_id) + self.launch(run) + return run + + async def resume(self, owner: str, run_id: str) -> Run: + run = await self.repository.resume(owner, run_id) + self.launch(run) + return run + + async def subscribe( + self, owner: str, run_id: str, *, after: int = 0 + ) -> AsyncGenerator[dict[str, Any] | None, None]: + # Authorization must happen before StreamingResponse sends HTTP headers. + await self.repository.get(owner, run_id) + cursor = after + quiet = 0 + while True: + events = await self.repository.events(owner, run_id, after=cursor) + for event in events: + cursor = event["seq"] + yield event + run = await self.repository.get(owner, run_id) + if (run.terminal or run.state == "waiting_user") and cursor >= run.last_seq: + return + if events: + quiet = 0 + continue + await asyncio.sleep(self.poll_interval) + quiet += self.poll_interval + if quiet >= self.heartbeat_seconds: + quiet = 0 + yield None + + async def close(self) -> None: + """Detach workers for shutdown; only an explicit stop interrupts Codex.""" + self._closing = True + if self._scheduler is not None: + self._scheduler.cancel() + await asyncio.gather(self._scheduler, return_exceptions=True) + workers = tuple(self._workers.values()) + for worker in workers: + worker.cancel() + await asyncio.gather(*workers, return_exceptions=True) + self._workers.clear() diff --git a/frontend/server/intelligent_development_runs/shell.py b/frontend/server/intelligent_development_runs/shell.py new file mode 100644 index 000000000..78e084b8a --- /dev/null +++ b/frontend/server/intelligent_development_runs/shell.py @@ -0,0 +1,163 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Reconcile delivery commands across HTTP loss and executor restarts. + +The native command ID supports wait. A remote receipt and exclusive lock close +the gap before that ID is received: submitting the same wrapper cannot execute +the underlying command twice. An abandoned receipt requires intervention. +""" + +from __future__ import annotations + +import asyncio +import json +import shlex +from typing import Any + +from frontend.server.sandbox_remote import SandboxRemoteTransport + +from .models import Run +from .repository import RunRepository + + +class CommandUnconfirmed(RuntimeError): + pass + + +def command_wrapper(root: str, command: str, timeout: int) -> str: + # No credentials or user content are embedded here. The command names only + # the delivery worker and its private request path. + source = f""" +import fcntl,json,os,signal,subprocess,time +root={root!r} +os.makedirs(root,mode=0o700,exist_ok=True) +lock=open(root+'/lock','a') +try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) +except BlockingIOError: raise SystemExit(0) +if os.path.exists(root+'/result.json'): raise SystemExit(0) +if os.path.exists(root+'/started'): raise SystemExit(2) +open(root+'/started','x').close() +result={{'status':'cancelled','exitCode':None,'output':''}} +if not os.path.exists(root+'/stop'): + with open(root+'/output','w+b') as output: + process=subprocess.Popen({command!r},shell=True,stdout=output,stderr=subprocess.STDOUT,start_new_session=True) + deadline=time.monotonic()+{timeout!r} + cancelled=False + while process.poll() is None: + if os.path.exists(root+'/stop') or time.monotonic()>=deadline or output.tell()>16*1024*1024: + cancelled=True + os.killpg(process.pid,signal.SIGTERM) + try: process.wait(timeout=3) + except subprocess.TimeoutExpired: + os.killpg(process.pid,signal.SIGKILL) + process.wait() + break + time.sleep(0.2) + output.seek(0) + result={{'status':'cancelled' if cancelled else 'completed','exitCode':process.returncode,'output':output.read(16*1024*1024).decode('utf-8',errors='replace')}} +with open(root+'/result.tmp','w') as stream: + json.dump(result,stream) + stream.flush() + os.fsync(stream.fileno()) +os.replace(root+'/result.tmp',root+'/result.json') +""" + return f"python3 -c {shlex.quote(source)}" + + +class RunShell: + def __init__( + self, + repository: RunRepository, + run: Run, + token: str, + transport: SandboxRemoteTransport, + root: str, + ): + self.repository, self.run, self.token, self.transport, self.root = ( + repository, + run, + token, + transport, + root, + ) + + async def _state(self) -> dict[str, Any]: + source = ( + "import json,os\n" + f"root={self.root!r}\n" + "path=root+'/result.json'\n" + "if os.path.exists(path):\n" + " with open(path) as stream: result=json.load(stream)\n" + "else: result={'status':'pending'}\n" + "print(json.dumps(result))\n" + ) + return await self.transport.exec_json( + f"python3 -c {shlex.quote(source)}", timeout=15 + ) + + async def stop(self) -> None: + source = f"import os; os.makedirs({self.root!r},mode=0o700,exist_ok=True); open({self.root + '/stop'!r},'a').close()" + await self.transport.exec_text(f"python3 -c {shlex.quote(source)}", timeout=15) + + async def execute(self, command: str, *, timeout: int) -> dict[str, Any]: + owner, run_id = self.run.owner_id, self.run.id + current = await self.repository.get(owner, run_id) + key = self.root.rsplit("/", 1)[-1] + command_id = str(current.checkpoint.get(key, "")) + state = await self._state() + if state.get("status") == "pending" and not command_id: + if current.stop_requested: + await self.stop() + # Retrying this wrapper is safe even if the first HTTP response was + # lost: its remote lock + started marker prevent duplicate execution. + await self.repository.checkpoint( + owner, run_id, self.token, **{f"{key}_submitted": True} + ) + data = await self.transport.start_command( + command_wrapper(self.root, command, timeout), hard_timeout=timeout + 15 + ) + command_id = str(data.get("session_id") or "") + if command_id: + await self.repository.checkpoint( + owner, run_id, self.token, **{key: command_id} + ) + for _ in range(timeout + 20): + current = await self.repository.get(owner, run_id) + await self.repository.heartbeat(owner, run_id, self.token) + if current.stop_requested: + await self.stop() + state = await self._state() + if state.get("status") != "pending": + break + if command_id: + await self.transport.wait_command(command_id) + else: + await asyncio.sleep(1) + else: + raise CommandUnconfirmed("Delivery command has no confirmed receipt") + if state.get("status") != "completed" or state.get("exitCode") != 0: + raise CommandUnconfirmed("Delivery command did not complete successfully") + try: + result = json.loads(state["output"]) + except (KeyError, ValueError, TypeError) as error: + raise CommandUnconfirmed("Delivery command result is invalid") from error + if not isinstance(result, dict): + raise CommandUnconfirmed("Delivery command result is invalid") + return result + + async def confirm_stopped(self) -> bool: + await self.stop() + state = await self._state() + return state.get("status") in {"completed", "cancelled"} diff --git a/frontend/server/intelligent_development_task.py b/frontend/server/intelligent_development_task.py index b6ba4504e..3846b7a47 100644 --- a/frontend/server/intelligent_development_task.py +++ b/frontend/server/intelligent_development_task.py @@ -23,18 +23,19 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from datetime import datetime, timezone import hashlib import json -from pathlib import PurePosixPath import re import shlex -from typing import Literal, cast +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import PurePosixPath +from typing import Any, Literal, cast from uuid import uuid4 import yaml +from jsonschema import Draft202012Validator from frontend.server.intelligent_development import ( REMOTE_DELIVERY_WORKER, @@ -44,7 +45,6 @@ ) from frontend.server.sandbox_remote import SandboxRemoteTransport - CredentialResolver = Callable[[], StudioCredentials] COMPLETION_SCHEMA_VERSION = "1" @@ -94,6 +94,54 @@ {"answered", "verified", "partial", "blocked", "indeterminate", "failed"} ) +# The revision is an echo of Studio's input context, never an authority supplied +# by the model. The repository checks it against the accepted input transaction. +BUILD_RESULT_TOOL = { + "type": "function", + "name": "submit_build_result", + "description": "Submit this turn's measured build result before the final answer. Correct validation errors in this same turn. This saves metadata only; it does not build or deploy anything.", + "inputSchema": { + "type": "object", + "additionalProperties": False, + "properties": { + "inputRevision": {"type": "integer", "minimum": 1, "maximum": 256}, + "schemaVersion": {"type": "string", "const": "1"}, + "status": {"type": "string", "enum": sorted(_TERMINAL_STATUSES)}, + "summary": {"type": "string", "minLength": 1, "maxLength": 2000}, + "intentSummary": {"type": "string", "maxLength": 2000}, + "runtimeName": { + "type": "string", + "maxLength": 64, + "pattern": "^(idv-[a-z0-9-]+)?$", + }, + "attemptCount": {"type": "integer", "minimum": 0, "maximum": 2}, + "gates": { + "type": "object", + "additionalProperties": False, + "properties": {gate: {"type": "boolean"} for gate in _REQUIRED_GATES}, + "required": list(_REQUIRED_GATES), + }, + "acceptanceCriteria": { + "type": "array", + "maxItems": 30, + "items": {"type": "string", "minLength": 1, "maxLength": 1000}, + }, + }, + "required": [ + "inputRevision", + "schemaVersion", + "status", + "summary", + "intentSummary", + "runtimeName", + "attemptCount", + "gates", + "acceptanceCriteria", + ], + }, +} +_BUILD_RESULT_VALIDATOR = Draft202012Validator(BUILD_RESULT_TOOL["inputSchema"]) + @dataclass(frozen=True) class IntentDecision: @@ -319,6 +367,7 @@ def builder_prompt( validation_region: str, validation_project: str, project_context: str = "", + input_revision: int | None = None, ) -> str: """Build one authoritative Codex turn without exposing credential values.""" criteria = json.dumps( @@ -382,6 +431,18 @@ def builder_prompt( template only when the accepted user intent explicitly requires a different application shape. Do not default to the `basic` template.""" ) + reporting_action = ( + f"Before finishing this turn, call submit_build_result with inputRevision={input_revision} and the JSON fields below. " + "Do not write a result file. If the tool rejects the result, correct the reported fields and call it again in this same turn. " + "A later user input revision invalidates an earlier submission; resubmit after applying the latest requirements." + if input_revision is not None + else f"Before finishing this turn, write exactly one UTF-8 JSON object to {completion_path}." + ) + reporting_check = ( + "Submit the contract through submit_build_result and wait for success before giving a concise user-facing summary." + if input_revision is not None + else "Read the contract back and verify its exact schema. Then give a concise user-facing summary." + ) return f"""Use the preinstalled veadk-agent-development Skill for this task. Follow it for implementation and validation; the operating constraints and current task below take precedence if anything conflicts. @@ -443,7 +504,7 @@ def builder_prompt( Keep user-facing progress and results in product language. Do not expose command lines, environment internals, filesystem paths, launcher details, or internal tool names to the user. -Before finishing this turn, write exactly one UTF-8 JSON object to {completion_path}. This is +{reporting_action} This is required for every outcome, including a read-only answer, clarification, refusal, or non-verified delivery result. It is secondary reporting metadata and must not replace the user-facing response, project, or validation work. It must contain exactly: @@ -466,8 +527,7 @@ def builder_prompt( confirmed absence is complete. Do not put command output, prompts, responses, credentials, endpoints, or tokens in this contract. After the successful final build and validation, do not change deliverable source before writing -the contract; the service packages the final project directory itself. Read the contract back and -verify its exact schema. Then give a concise user-facing summary. +the contract; the service packages the final project directory itself. {reporting_check} For `answered`, respond naturally without delivery headings. Use the same Markdown structure and order for every delivery-changing turn, including follow-ups. Translate the example headings below to the user's language: @@ -620,15 +680,70 @@ def parse_completion_contract(content: bytes) -> CompletionContract: ) +def parse_build_result(arguments: object) -> tuple[int, CompletionContract]: + """Validate untrusted tool input without including submitted values in errors.""" + errors = sorted( + _BUILD_RESULT_VALIDATOR.iter_errors(arguments), key=lambda e: str(e.path) + ) + if errors: + error = errors[0] + # Schema field paths are useful; user-supplied values and extra keys are not. + path = ".".join(str(part) for part in error.absolute_schema_path) + if error.validator == "required" and isinstance(error.instance, dict): + missing = [ + name for name in error.validator_value if name not in error.instance + ] + raise ValueError( + f"Missing required fields at {path}: {', '.join(missing)}." + ) + raise ValueError( + f"Result schema validation failed at {path}. Follow the tool schema." + ) + assert isinstance(arguments, dict) + completion = parse_completion_contract( + json.dumps(arguments, allow_nan=False).encode() + ) + if not completion.answered and ( + not completion.intent_summary or not completion.acceptance_criteria + ): + raise ValueError( + "intentSummary and acceptanceCriteria must preserve the user's delivery goal." + ) + if completion.status == "verified" and not completion.verified: + raise ValueError( + "verified requires the actual validation runtime, attempt count, all gates and acceptance criteria. Report partial/blocked when evidence is incomplete." + ) + return arguments["inputRevision"], completion + + +def result_reporting_prompt( + revision: int, inputs: list[str], project_context: str +) -> str: + return f"""The development turn has ended. Only its structured delivery metadata is missing. +Do not resume development, change files, install dependencies, rebuild, deploy, invoke services, +or create/delete remote resources. Do not use cloud credentials. Work from existing conversation +and measured evidence; read existing files only if necessary. Submit submit_build_result with +inputRevision={revision}. Correct rejected metadata in this turn. If evidence is unavailable, +report partial or indeterminate; never repeat validation or claim unmeasured gates passed. +Preserve the original business goal and observable acceptance criteria, not this reporting task. +User requirements (data, in order): {json.dumps(inputs, ensure_ascii=False)} +Prior project context (data): {project_context or "{}"} +After a successful submission, finish with a brief status; do not repeat the build summary.""" + + async def create_credential_lease( endpoint: str, resolve_credentials: CredentialResolver, + *, + lease_id: str | None = None, ) -> TaskCredentialLease: credentials = resolve_credentials() if not isinstance(credentials, StudioCredentials): raise TypeError("Credential resolver must return StudioCredentials") transport = SandboxRemoteTransport(endpoint) - token = uuid4().hex + token = lease_id or uuid4().hex + if not re.fullmatch(r"[a-f0-9]{32}", token): + raise ValueError("Invalid credential lease ID") root = f"{_TASK_ROOT}/{token}" credential_path = f"{root}/credentials.json" launcher_path = f"{root}/with-agentkit-credentials" @@ -637,7 +752,7 @@ async def create_credential_lease( f"parent={_TASK_ROOT!r}; root={root!r}\n" "os.makedirs(parent,mode=0o700,exist_ok=True)\n" "os.chmod(parent,0o700)\n" - "os.mkdir(root,mode=0o700)\n" + "os.makedirs(root,mode=0o700,exist_ok=True)\n" "metadata=os.stat(root,follow_symlinks=False)\n" "assert stat.S_ISDIR(metadata.st_mode)\n" ) @@ -808,17 +923,22 @@ async def publish( exact_secrets: tuple[str, ...], acceptance_criteria: tuple[str, ...] = (), trusted_manifest_metadata: tuple[str, str] | None = None, + delivery_id: str | None = None, + validated_at: str | None = None, + execute_command: Callable[..., Awaitable[dict[str, Any]]] | None = None, ) -> DeliveryReference: trusted_metadata = ( _validate_delivery_metadata(*trusted_manifest_metadata) if trusted_manifest_metadata is not None else None ) - token = uuid4().hex + token = delivery_id or uuid4().hex + if not re.fullmatch(r"[a-f0-9]{32}", token): + raise ValueError("Invalid delivery ID") worker_path = f"{task_root}/delivery-{token}.py" request_path = f"{task_root}/delivery-{token}.json" secret_path = f"{task_root}/delivery-secrets-{token}.json" - now = datetime.now(timezone.utc).isoformat() + now = validated_at or datetime.now(timezone.utc).isoformat() verified = completion is not None and completion.verified gates = ( completion.gates @@ -879,6 +999,7 @@ async def publish( json.dumps(request, separators=(",", ":")).encode(), media_type="application/json", ) + completed = False try: await self._transport.upload( secret_path, @@ -886,10 +1007,11 @@ async def publish( media_type="application/json", mode=0o600, ) - value = await self._transport.exec_json( + value = await (execute_command or self._transport.exec_json)( f"python3 {shlex.quote(worker_path)} {shlex.quote(request_path)}", timeout=180, ) + completed = True return self._reference( value, session_id, @@ -899,7 +1021,10 @@ async def publish( gate_summary=tuple(name for name in _REQUIRED_GATES if gates[name]), ) finally: - await self._unlink_many(secret_path, request_path, worker_path) + # A recoverable executor can still be reading these after HTTP loss. + # Its task lease owns cleanup until the command receipt is confirmed. + if completed or execute_command is None: + await self._unlink_many(secret_path, request_path, worker_path) async def _manifest_bytes(self, project_root: str, *, allow_missing: bool) -> bytes: manifest_path = f"{project_root}/agentkit.yaml" @@ -1007,10 +1132,10 @@ def _reference( __all__ = [ "COMPLETION_FILE_PREFIX", + "INTENT_DECISION_OUTPUT_SCHEMA", "CompletionContract", "CredentialResolver", "DeliveryPublisher", - "INTENT_DECISION_OUTPUT_SCHEMA", "IntentDecision", "TaskCredentialLease", "builder_prompt", @@ -1019,7 +1144,7 @@ def _reference( "invalidate_current_delivery", "parse_completion_contract", "parse_intent_decision", - "read_only_prompt", "read_completion_contract", + "read_only_prompt", "remove_completion_file", ] diff --git a/frontend/server/sandbox_remote.py b/frontend/server/sandbox_remote.py index 3fb05c522..249bc46db 100644 --- a/frontend/server/sandbox_remote.py +++ b/frontend/server/sandbox_remote.py @@ -49,6 +49,10 @@ class SandboxRemoteSizeError(SandboxRemoteResponseError): pass +class SandboxRemoteFileNotFound(SandboxRemoteResponseError, FileNotFoundError): + """An authoritative missing file, distinct from a failed download.""" + + def _exact_file_path(value: str) -> str: if ( not isinstance(value, str) @@ -86,6 +90,58 @@ def __init__(self, endpoint: str, *, read_attempts: int = 2) -> None: self._endpoint = endpoint self._read_attempts = read_attempts + async def start_command(self, command: str, *, hard_timeout: int) -> dict[str, Any]: + """Return the native command identity; a timeout has an unknown outcome.""" + return await self._shell_request( + "/v1/shell/exec", + { + "id": "", + "exec_dir": "/home/gem", + "command": command, + "timeout": 1, + "hard_timeout": hard_timeout, + "strict": True, + }, + timeout=15, + ) + + async def wait_command(self, command_id: str) -> dict[str, Any]: + if not command_id: + raise ValueError("Sandbox command ID is required") + return await self._shell_request( + "/v1/shell/wait", + { + "id": command_id, + "seconds": 1, + "max_wait_seconds": 1, + }, + timeout=15, + ) + + async def _shell_request( + self, route: str, body: dict[str, Any], *, timeout: int + ) -> dict[str, Any]: + def operation(): + response = requests.post( + build_file_url(self._endpoint, route), json=body, timeout=(5, timeout) + ) + self._check_response(response, retry_conflict=False) + payload = response.json() + if not isinstance(payload, dict) or not isinstance( + payload.get("data"), dict + ): + raise SandboxRemoteResponseError("Sandbox command state is invalid") + return payload["data"] + + try: + return await asyncio.to_thread(operation) + except SandboxRemoteError: + raise + except Exception as error: + raise SandboxRemoteError( + "Sandbox command state is unknown", retryable=True + ) from error + async def exec_text(self, command: str, *, timeout: int = 12) -> str: """Execute once. Callers reconcile ambiguous mutating outcomes themselves.""" payload = await asyncio.to_thread(self._post_exec, command, timeout) @@ -198,6 +254,8 @@ def operation() -> bytes: timeout=(10, 120), stream=True, ) as response: + if response.status_code == 404: + raise SandboxRemoteFileNotFound("Sandbox file does not exist") self._check_response(response, retry_conflict=True) content = bytearray() for chunk in response.iter_content(chunk_size=64 * 1024): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c638ba4bb..f1a383658 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,7 @@ +import { SandboxSpinnerIcon } from "./ui/icons/SandboxControlIcons"; +import { useDevelopmentRun } from "./create/useDevelopmentRun"; +import { developmentRunStatus } from "./create/developmentPresentation"; +import { DevelopmentTaskNotice, type DevelopmentTaskSnapshot } from "./create/DevelopmentTaskNotice"; import { UserManagement } from "./users/UserManagement"; import { useCallback, @@ -192,7 +196,6 @@ import { intelligentDevelopmentErrorMessage, intelligentDevelopmentClient, sandboxClient, - SandboxServiceError, type SandboxApproval, type SandboxApprovalDecision, type SandboxAgentResource, @@ -806,7 +809,7 @@ function turnHasVisibleContent(turn: Turn): boolean { if (b.kind === "text") return b.text.trim().length > 0; if (b.kind === "attachment") return b.files.length > 0; if (b.kind === "artifact") return b.files.length > 0; - if (b.kind === "delivery") return true; + if (b.kind === "delivery" || b.kind === "turn-summary") return true; if (b.kind === "tool") return !(b.name === A2UI_TOOL_NAME && b.done); if (b.kind === "agent-transfer") return false; if (b.kind === "a2ui") return buildSurfaces(b.messages).some((s) => s.components[s.rootId]); @@ -1172,13 +1175,8 @@ export default function App() { const sandboxLaunchAbortRef = useRef(null); const sandboxLaunchCapabilityAbortRef = useRef(null); const intelligentCreateAbortRef = useRef(null); + const intelligentCreateFocusRef = useRef(null); const sandboxMessageAbortRef = useRef(null); - const pendingIntelligentNavigationRef = useRef<(() => void) | null>(null); - const [intelligentLeaveOpen, setIntelligentLeaveOpen] = useState(false); - const sandboxStopWaitRef = useRef<{ - controller: AbortController; - promise: Promise; - } | null>(null); const sandboxSessionIdRef = useRef(sandboxSession?.id ?? ""); const sandboxActiveAssistantTurnIdRef = useRef(""); const sandboxUploadRunRef = useRef(0); @@ -1314,6 +1312,9 @@ export default function App() { useState(true); const [intelligentCapabilitiesError, setIntelligentCapabilitiesError] = useState(""); + const [developmentTaskSnapshot, setDevelopmentTaskSnapshot] = useState(); + const [developmentTaskRefreshKey, setDevelopmentTaskRefreshKey] = useState(0); + const [intelligentPreparationMessage, setIntelligentPreparationMessage] = useState(""); const [intelligentPreparationStage, setIntelligentPreparationStage] = useState(null); const [migrationProjectReturn, setMigrationProjectReturn] = useState<{ @@ -1898,7 +1899,7 @@ export default function App() { }); useEffect(() => { const activeSession = sandboxSession; - if (!activeSession || !sandboxBusy || sandboxMessageAbortRef.current) return; + if (!activeSession || activeSession.intelligentDevelopment || !sandboxBusy || sandboxMessageAbortRef.current) return; let stopped = false; let timer: number | undefined; const controller = new AbortController(); @@ -1970,6 +1971,15 @@ export default function App() { if (timer !== undefined) window.clearTimeout(timer); }; }, [sandboxBusy, sandboxSession?.id]); + const development = useDevelopmentRun({ + sessionId: sandboxSession?.intelligentDevelopment ? sandboxSession.id : "", + ownerId: userId, + onTurns: setSandboxTurns, + onBusy: (busy) => { + setSandboxBusy(busy); + setSandboxSession((current) => current?.intelligentDevelopment ? { ...current, busy } : current); + }, + }); const activeAgent = activeAgentBySession[sessionId] ?? ""; const seenAgents = seenAgentsBySession[sessionId] ?? EMPTY_STRING_SET; const execPath = execPathBySession[sessionId] ?? EMPTY_STRING_ARR; @@ -3882,6 +3892,14 @@ export default function App() { setSandboxAgentWorkspace(null); } + async function openIntelligentDevelopmentTask(id: string, signal: AbortSignal) { + const connected = await intelligentDevelopmentClient.connectSession(id, { signal }); + if (signal.aborted) return; + setError(""); + setMigrationProjectReturn(undefined); + activateIntelligentDevelopmentSession(connected, []); + } + async function openSandboxAgent( resource: SandboxAgentResource, source: AgentConnectSource = "my_agents", @@ -4085,7 +4103,7 @@ export default function App() { sandboxUploadRunRef.current += 1; const closingSession = sandboxSession; setSandboxSession(null); - if (closingSession && closeRemote) { + if (closingSession && closeRemote && !closingSession.intelligentDevelopment) { const closingClient = closingSession.intelligentDevelopment ? intelligentDevelopmentClient : sandboxClient; @@ -4376,32 +4394,11 @@ export default function App() { function stopSandboxGeneration() { const controller = sandboxMessageAbortRef.current; const activeSession = sandboxSession; - if (!controller) return; if (activeSession?.intelligentDevelopment) { - if (sandboxStopWaitRef.current?.controller === controller) return; - const promise = intelligentDevelopmentClient - .interruptSession(activeSession.id) - .then(() => { - if (sandboxStopWaitRef.current?.controller === controller) { - controller.abort(); - } - return true; - }) - .catch((cause) => { - if (sandboxStopWaitRef.current?.controller === controller) { - sandboxStopWaitRef.current = null; - } - if ( - sandboxSessionIdRef.current === activeSession.id && - sandboxMessageAbortRef.current === controller - ) { - setError(cause instanceof Error ? cause.message : String(cause)); - } - return false; - }); - sandboxStopWaitRef.current = { controller, promise }; + void development.stop(); return; } + if (!controller) return; controller.abort(); if (activeSession) { void sandboxClient.interruptSession(activeSession.id).catch((cause) => { @@ -4413,35 +4410,7 @@ export default function App() { } function requestIntelligentNavigation(action: () => void) { - if (sandboxSession?.intelligentDevelopment && sandboxBusy) { - pendingIntelligentNavigationRef.current = action; - setIntelligentLeaveOpen(true); - return; - } - action(); - } - - function confirmIntelligentNavigation() { - const activeSession = sandboxSession; - const action = pendingIntelligentNavigationRef.current; - if (!activeSession?.intelligentDevelopment || !action) { - setIntelligentLeaveOpen(false); - pendingIntelligentNavigationRef.current = null; - return; - } - setError(""); - const interrupt = intelligentDevelopmentClient.interruptSession(activeSession.id); - sandboxMessageAbortRef.current?.abort(); - pendingIntelligentNavigationRef.current = null; - setIntelligentLeaveOpen(false); action(); - void interrupt.catch(() => { - if (!sandboxSessionIdRef.current) { - setError( - appText("errors.buildStopUnconfirmed"), - ); - } - }); } async function sendSandboxMessage( @@ -4451,6 +4420,11 @@ export default function App() { activeSessionOverride?: SandboxSessionInfo, ) { const activeSession = activeSessionOverride ?? sandboxSession; + if (activeSession?.intelligentDevelopment) { + const accepted = await development.submit(text.trim(), activeSession.id); + if (accepted) setInput((current) => current === text ? "" : current); + return; + } const readyAttachments = messageAttachments.filter( (attachment) => attachment.status === "ready" && attachment.uri, ); @@ -4533,10 +4507,7 @@ export default function App() { ? { ...current, busy: true, workspaceLocked: true } : current, ); - const activeClient = activeSession.intelligentDevelopment - ? intelligentDevelopmentClient - : sandboxClient; - let remainingBusy = false; + const activeClient = sandboxClient; try { const reply = await activeClient.sendMessage( { @@ -4638,9 +4609,7 @@ export default function App() { } return next; }); - if (!activeSession.intelligentDevelopment) { - void sandboxCommands.refreshThreads(); - } + void sandboxCommands.refreshThreads(); } catch (messageError) { operation.fail({ sessionId: String(activeSession.id), @@ -4653,24 +4622,11 @@ export default function App() { if (sandboxMessageAbortRef.current !== controller) { return; } - setSandboxTurns((current) => - current.filter( - (turn) => - turn.meta?.localId !== userTurnId && - turn.meta?.localId !== assistantTurnId, - ), - ); setInput(text); setAttachments(messageAttachments); sandboxCommands.setSelectedSkills(selectedSkills); - const taskStillRunning = - activeSession.intelligentDevelopment && - messageError instanceof SandboxServiceError && - messageError.code === "INTELLIGENT_DEVELOPMENT_TASK_IN_PROGRESS"; - remainingBusy = activeSession.intelligentDevelopment; try { const status = await activeClient.getStatus(activeSession.id); - remainingBusy = status.busy; setSandboxSession((current) => current?.id === activeSession.id ? { ...current, ...status } @@ -4679,58 +4635,33 @@ export default function App() { } catch { // Keep the optimistic lock when the connection itself is unavailable. } - if (!taskStillRunning) { - setError( - activeSession.intelligentDevelopment - ? intelligentDevelopmentErrorMessage(messageError) - : appText("errors.builtinAgentSendFailed", { - message: messageError instanceof Error - ? messageError.message - : String(messageError), - }), - ); - } + setError(appText("errors.builtinAgentSendFailed", { + message: messageError instanceof Error + ? messageError.message + : String(messageError), + })); } finally { if (sandboxMessageAbortRef.current === controller) { - const stopWait = sandboxStopWaitRef.current; - if (stopWait?.controller === controller) { - const cleanupConfirmed = await stopWait.promise; - if (sandboxStopWaitRef.current === stopWait) { - sandboxStopWaitRef.current = null; - } - if (cleanupConfirmed) { - setSandboxTurns((current) => current.filter( - (turn) => - turn.meta?.localId !== assistantTurnId || turn.blocks.length > 0, - )); - appendSandboxActivity(activeSession.id, appText("sandbox.stoppedReady")); - } - } sandboxMessageAbortRef.current = null; if (sandboxActiveAssistantTurnIdRef.current === assistantTurnId) { sandboxActiveAssistantTurnIdRef.current = ""; } setSandboxApproval(null); - if (activeSession.intelligentDevelopment) { - setSandboxBusy(remainingBusy); - setSandboxSession((current) => - current?.id === activeSession.id - ? { ...current, busy: remainingBusy } - : current, - ); - } else { - setSandboxBusy(false); - setSandboxSession((current) => - current?.id === activeSession.id - ? { ...current, busy: false } - : current, - ); - } + setSandboxBusy(false); + setSandboxSession((current) => + current?.id === activeSession.id + ? { ...current, busy: false } + : current, + ); } } } async function submitSandboxInput(value: string) { + if (sandboxSession?.intelligentDevelopment) { + await sendSandboxMessage(value); + return; + } if ( !sandboxSession?.intelligentDevelopment && await sandboxCommands.executeSlash(value) @@ -4817,6 +4748,11 @@ export default function App() { intelligentCreateAbortRef.current?.abort(); intelligentCreateAbortRef.current = null; setIntelligentPreparationStage(null); + const trigger = intelligentCreateFocusRef.current; + intelligentCreateFocusRef.current = null; + requestAnimationFrame(() => { + if (trigger?.isConnected && trigger.getClientRects().length) trigger.focus(); + }); } async function startIntelligentDevelopment( @@ -4829,6 +4765,9 @@ export default function App() { intelligentCreateAbortRef.current?.abort(); const controller = new AbortController(); intelligentCreateAbortRef.current = controller; + intelligentCreateFocusRef.current = document.activeElement instanceof HTMLElement + ? document.activeElement : null; + setIntelligentPreparationMessage(goal); setIntelligentPreparationStage("preparing"); setIntelligentCapabilitiesError(""); try { @@ -4857,12 +4796,17 @@ export default function App() { intelligentCreateAbortRef.current !== controller ) return; if (returnTarget) setMigrationProjectReturn(returnTarget); - activateIntelligentDevelopmentSession(connected, []); + activateIntelligentDevelopmentSession(connected, development.prepare(goal, connected.id)); intelligentCreateAbortRef.current = null; + intelligentCreateFocusRef.current = null; setIntelligentPreparationStage(null); await sendSandboxMessage(goal, [], [], connected); } catch (cause) { - if ((cause as Error)?.name !== "AbortError") { + if ( + !controller.signal.aborted + && intelligentCreateAbortRef.current === controller + && (cause as Error)?.name !== "AbortError" + ) { setIntelligentCapabilitiesError( cause instanceof Error ? cause.message @@ -6532,6 +6476,11 @@ export default function App() { return (
+ void submitSandboxInput(value)} - onStop={sandboxBusy ? stopSandboxGeneration : undefined} + onStop={sandboxBusy || development.submitting ? stopSandboxGeneration : undefined} disabled={false} + allowSteer={sandboxSession.intelligentDevelopment} + sending={sandboxSession.intelligentDevelopment && development.submitting} + stopping={sandboxSession.intelligentDevelopment && (development.stopPending || development.run?.state === "stopping")} + errorText={sandboxSession.intelligentDevelopment ? development.error : undefined} + onResume={sandboxSession.intelligentDevelopment && development.run?.state === "waiting_user" ? () => void development.resume() : undefined} busy={sandboxBusy || sandboxCommands.commandBusy} attachments={attachments} onAddFiles={addSandboxFiles} @@ -7029,6 +6983,23 @@ export default function App() { )}
); + const preparation = intelligentPreparationStage ? ( +
+
+
+
+
+ +
+ {t("adk:developmentRuns.preparingEnvironment")} + {t("adk:developmentRuns.connectingEnvironment")} +
+ +
+
+
+
+ ) : null; return (
) : visibleCreateView === "intelligent" ? ( + <> + + {preparation} + ) : visibleCreateView === "deepseek" ? ( ) : visibleCreateView === "migration" ? ( + <> + + {preparation} + ) : turns.length === 0 && !newChatCapabilitiesReady ? (
{t("loading.agentCapabilities")} @@ -7787,9 +7772,9 @@ export default function App() { const turnInvocation = turn.blocks.find((b) => b.kind === "invocation"); return ( @@ -7809,6 +7794,7 @@ export default function App() { data-share-image-exclude="true" > {turn.meta?.ts && {fmtTime(turn.meta.ts)}} + {turn.activity && {turn.activity.title}}
@@ -7874,7 +7860,7 @@ export default function App() { aria-label={canAnnotate ? t("conversation.annotationHint") : undefined} - initial={{ opacity: 0, y: 8 }} + initial={sandboxSession?.intelligentDevelopment ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.2, ease: "easeOut" }} > @@ -7893,12 +7879,17 @@ export default function App() { )} {pending ? ( - turnIsStreaming ? : null + turnIsStreaming ? (sandboxSession?.intelligentDevelopment + ? + : ) : null ) : ( <> - {sandboxSession && turn.meta?.sandboxUsage ? ( + {sandboxSession && !sandboxSession.intelligentDevelopment && turn.meta?.sandboxUsage ? ( ) : null}
@@ -8160,20 +8151,6 @@ export default function App() { onOpenSession={openCodexHandoffSession} /> - {intelligentLeaveOpen ? ( - { - pendingIntelligentNavigationRef.current = null; - setIntelligentLeaveOpen(false); - }} - onConfirm={confirmIntelligentNavigation} - /> - ) : null} - {sandboxThreadDeleteTarget ? ( ; +} +export const runEnded = (run: DevelopmentRun) => + ["succeeded", "cancelled", "failed"].includes(run.state); +const base = "/web/intelligent-development"; +export class DevelopmentRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} +async function request( + path: string, + method = "GET", + body?: unknown, + signal?: AbortSignal, +): Promise { + const response = await studioFetch( + `${base}${path}`, + { + method, + signal, + headers: sandboxHeaders( + body === undefined ? undefined : { "Content-Type": "application/json" }, + ), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }, + 30_000, + ); + if (!response.ok) { + const value = await response.json().catch(() => null); + const detail = value?.detail; + throw new DevelopmentRequestError( + typeof detail === "string" + ? detail + : detail?.message || adkT("developmentRuns.requestFailed"), + response.status, + ); + } + return response.json(); +} +function parseRun(value: unknown): DevelopmentRun { + const run = value as DevelopmentRun; + if ( + !run || + typeof run.runId !== "string" || + typeof run.sessionId !== "string" || + ![ + "queued", + "running", + "recovering", + "waiting_user", + "stopping", + "succeeded", + "cancelled", + "failed", + ].includes(run.state) + ) + throw new Error(adkT("developmentRuns.invalidResponse")); + return run; +} +export const developmentRuns = { + async active(signal?: AbortSignal): Promise { + const result = (await request("/runs", "GET", undefined, signal)) as { + runs: unknown[]; + }; + if (!Array.isArray(result.runs)) + throw new Error(adkT("developmentRuns.invalidResponse")); + return result.runs.map(parseRun); + }, + async get(runId: string, signal?: AbortSignal): Promise { + return parseRun( + await request( + `/runs/${encodeURIComponent(runId)}`, + "GET", + undefined, + signal, + ), + ); + }, + async list( + sessionId: string, + signal?: AbortSignal, + ): Promise { + const result = (await request( + `/sessions/${encodeURIComponent(sessionId)}/runs`, + "GET", + undefined, + signal, + )) as { runs: unknown[] }; + if (!Array.isArray(result.runs)) + throw new Error(adkT("developmentRuns.invalidResponse")); + return result.runs.map(parseRun); + }, + async create( + sessionId: string, + message: string, + requestId: string, + ): Promise { + return parseRun( + await request(`/sessions/${encodeURIComponent(sessionId)}/runs`, "POST", { + message, + requestId, + }), + ); + }, + async steer(runId: string, message: string, clientId: string): Promise { + await request(`/runs/${encodeURIComponent(runId)}/inputs`, "POST", { + message, + clientId, + }); + }, + async stop(runId: string): Promise { + return parseRun( + await request(`/runs/${encodeURIComponent(runId)}/stop`, "POST"), + ); + }, + async resume(runId: string): Promise { + return parseRun( + await request(`/runs/${encodeURIComponent(runId)}/resume`, "POST"), + ); + }, +}; + +/** Run-local projection sharing Studio's existing rich block renderer. */ +export class DevelopmentRunProjection { + cursor = 0; + turns: Turn[] = []; + private activeInput: string; + private itemTurns = new Map(); + private nativeInputs = new Map(); + private metrics = new Map(); + private inputOrder: string[] = []; + private projections = new Map< + string, + ReturnType + >(); + private itemInputs = new Map(); + constructor(public run: DevelopmentRun) { + this.activeInput = run.requestId; + } + private projection(input: string) { + let projection = this.projections.get(input); + if (!projection) { + const turn: Turn = { + role: "assistant", + blocks: [], + meta: { localId: `${input}:assistant` }, + }; + const userIndex = this.turns.findIndex( + (item) => item.meta?.localId === `${input}:user`, + ); + this.turns.splice( + userIndex < 0 ? this.turns.length : userIndex + 1, + 0, + turn, + ); + projection = createSandboxProjection({ + onBlocks: (blocks) => { + turn.blocks = blocks.map(block => ({...block, turnId: this.itemTurns.get(block.id || "")})); + }, + onUsage: (usage) => { + turn.meta = { ...turn.meta, sandboxUsage: usage.usage }; + }, + }); + this.projections.set(input, projection); + } + return projection; + } + apply(event: DevelopmentEvent): void { + if (event.payload.runId !== this.run.runId) + throw new Error(adkT("developmentRuns.invalidResponse")); + if (event.seq <= this.cursor) return; + if (event.seq !== this.cursor + 1) + throw new Error(adkT("developmentRuns.eventGap")); + for (const turn of this.turns) if (turn.blocks.some(block => block.kind === "turn-summary")) + turn.blocks = turn.blocks.filter(block => block.kind !== "turn-summary"); + const value = event.payload; + if ( + event.type === "run.input" && + typeof value.clientId === "string" && + typeof value.message === "string" + ) { + this.turns.push({ + role: "user", + blocks: [{ kind: "text", text: value.message }], + meta: { localId: `${value.clientId}:user` }, + activity: { + id: `${value.clientId}:delivery`, + title: adkT(`developmentRuns.input.${String(value.status)}`), + }, + }); + this.inputOrder.push(value.clientId); + this.projection(value.clientId); + if ( + value.status === "delivered" && + this.inputOrder.indexOf(value.clientId) >= + this.inputOrder.indexOf(this.activeInput) + ) + this.activeInput = value.clientId; + } else if ( + event.type === "run.input_status" && + typeof value.clientId === "string" + ) { + const turn = this.turns.find( + (item) => + item.meta?.localId === `${value.clientId}:user`, + ); + if (turn) + turn.activity = { + id: `${value.clientId}:delivery`, + title: adkT(`developmentRuns.input.${String(value.status)}`), + }; + if ( + value.status === "delivered" && + this.inputOrder.indexOf(value.clientId) >= + this.inputOrder.indexOf(this.activeInput) + ) + this.activeInput = value.clientId; + } else if (event.type === "run.turn" && typeof value.turnId === "string") { + const previous = this.metrics.get(value.turnId); + const metric: DevelopmentTurnMetrics = { + ...previous, turnId: value.turnId, status: String(value.status || "inProgress"), + toolCalls: 0, toolDurationComplete: false, + }; + for (const key of ["startedAt", "completedAt", "durationMs"] as const) + if (typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) metric[key] = value[key]; + if (typeof value.model === "string") metric.model = value.model; + if (value.usage && typeof value.usage === "object" && !Array.isArray(value.usage)) { + const usage: NonNullable = {}; + for (const key of ["totalTokens", "inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteInputTokens", "reasoningOutputTokens"] as const) { + const count = (value.usage as Record)[key]; + if (typeof count === "number" && Number.isSafeInteger(count) && count >= 0) usage[key] = count; + } + metric.usage = usage; + } + if (value.usageIncomplete === true) metric.usageIncomplete = true; + this.metrics.set(value.turnId, metric); + this.nativeInputs.set(value.turnId, this.activeInput); + this.projection(this.activeInput); + } else if (event.type === "run.status") { + this.run = { ...this.run, ...value } as DevelopmentRun; + if (runEnded(this.run)) for (const [id, metric] of this.metrics) { + if (metric.status === "inProgress") this.metrics.set(id, {...metric, status: "unavailable", usageIncomplete: true}); + } + if (runEnded(this.run) || this.run.state === "waiting_user") + for (const projection of this.projections.values()) + projection.consumeFrame("event: done\ndata: {}"); + } else if (!event.type.startsWith("run.")) { + const nativeId = typeof value.id === "string" && value.id ? value.id + : ["plan", "diff"].includes(event.type) ? event.type : ""; + const itemId = nativeId ? `${String(value.turnId || "")}:${nativeId}` : ""; + const input = (itemId && this.itemInputs.get(itemId)) || this.activeInput; + if (itemId) { + this.itemInputs.set(itemId, input); + if (typeof value.turnId === "string") this.itemTurns.set(itemId, value.turnId); + } + if (typeof value.turnId === "string") this.nativeInputs.set(value.turnId, this.activeInput); + const commentary = + event.type === "activity" && value.kind === "commentary"; + this.projection(input).consumeFrame( + `event: ${commentary ? "delta" : event.type}\ndata: ${JSON.stringify({ ...value, ...(itemId ? { id: itemId } : {}), ...(commentary ? { snapshot: true } : {}) })}`, + ); + } + if ((event.type === "run.input" || event.type === "run.input_status") && + value.status === "delivered" && typeof value.turnId === "string" && typeof value.clientId === "string") + this.nativeInputs.set(value.turnId, value.clientId); + this.decorate(); + this.cursor = event.seq; + } + private decorate() { + const active = !runEnded(this.run) && this.run.state !== "waiting_user"; + const last = [...this.turns].reverse().find(turn => turn.role === "assistant"); + for (const turn of this.turns) { + if (turn.role === "assistant") turn.meta = {...turn.meta, streaming: active && turn === last}; + } + for (const metric of this.metrics.values()) { + if (!["completed", "failed", "interrupted", "cancelled", "unavailable"].includes(metric.status)) continue; + const tools = new Map>(); + for (const turn of this.turns) for (const block of turn.blocks) { + if (block.kind === "tool" && block.turnId === metric.turnId && block.id) tools.set(block.id, block); + } + const measured = [...tools.values()].filter(block => typeof block.durationMs === "number" && Number.isFinite(block.durationMs) && block.durationMs >= 0); + const value: DevelopmentTurnMetrics = {...metric, toolCalls: tools.size, + toolDurationComplete: measured.length === tools.size, + toolDurationMs: measured.length || !tools.size ? measured.reduce((sum, block) => sum + block.durationMs!, 0) : undefined}; + const input = this.nativeInputs.get(metric.turnId); + const target = this.turns.find(turn => turn.meta?.localId === `${input}:assistant`); + if (target) { + const lastIndex = target.blocks.reduce((last, block, i) => block.turnId === metric.turnId ? i : last, -1); + target.blocks.splice(lastIndex < 0 ? target.blocks.length : lastIndex + 1, 0, + {kind: "turn-summary", id: `${metric.turnId}:summary`, turnId: metric.turnId, value}); + } + } + } +} +function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const abort = () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(); + }, ms); + signal.addEventListener("abort", abort, { once: true }); + }); +} +async function readEvents( + view: DevelopmentRunProjection, + signal: AbortSignal, + update: () => void, +): Promise { + const response = await studioFetch( + `${base}/runs/${encodeURIComponent(view.run.runId)}/events?after=${view.cursor}`, + { signal, headers: sandboxHeaders({ Accept: "text/event-stream" }) }, + 0, + ); + if (!response.ok) + throw new DevelopmentRequestError( + adkT("developmentRuns.requestFailed"), + response.status, + ); + if (!response.body) throw new Error(adkT("developmentRuns.invalidResponse")); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let ended = false; + try { + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + const frames = buffer.split(/\r?\n\r?\n/); + buffer = frames.pop() || ""; + for (const frame of frames) { + const lines = frame.split(/\r?\n/); + const type = lines + .find((line) => line.startsWith("event:")) + ?.slice(6) + .trim(); + if (type === "done") { + ended = true; + continue; + } + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!type || !data) continue; + const payload = JSON.parse(data) as Record; + if (!Number.isSafeInteger(payload.seq)) + throw new Error(adkT("developmentRuns.invalidResponse")); + view.apply({ seq: payload.seq as number, type, payload }); + } + update(); + if (done) break; + } + if (!ended) throw new Error(adkT("developmentRuns.reconnecting")); + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} +/** No browser persistence: identity changes discard projections and subscriptions. */ +export async function observeDevelopmentRuns( + sessionId: string, + signal: AbortSignal, + onUpdate: (turns: Turn[], current: DevelopmentRun | null) => void, + onConnection: (message: string) => void, + onReady?: (seed: (run: DevelopmentRun) => void) => void, +): Promise { + const views = new Map(); + let retry = 0; + const seeds = new Map(); + let wake: (() => void) | undefined; + onReady?.((run) => { + if (signal.aborted || run.sessionId !== sessionId) return; + seeds.set(run.runId, run); + wake?.(); + }); + const pause = (ms: number) => new Promise((resolve) => { + const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); wake = undefined; resolve(); }; + const timer = setTimeout(finish, ms); + wake = finish; + signal.addEventListener("abort", finish, { once: true }); + if (signal.aborted || seeds.size) finish(); + }); + const snapshots = new Map(); + const update = () => { + if (signal.aborted) return; + const all = [...views.values()]; + onUpdate( + all.flatMap((view) => { + let snapshot = snapshots.get(view); + if (!snapshot || snapshot.cursor !== view.cursor) { + snapshot = { cursor: view.cursor, turns: view.turns.map((turn) => ({ ...turn })) }; + snapshots.set(view, snapshot); + } + return snapshot.turns; + }), + all[all.length - 1]?.run || null, + ); + }; + while (!signal.aborted) { + try { + const listed = seeds.size ? [] : await developmentRuns.list(sessionId, signal); + const runs = [...new Map([...listed, ...seeds.values()].map((run) => [run.runId, run])).values()]; + seeds.clear(); + for (const run of runs) { + let view = views.get(run.runId); + if (!view) { + view = new DevelopmentRunProjection(run); + views.set(run.runId, view); + } + if (view.cursor >= run.lastSeq) view.run = run; + if ( + view.cursor < run.lastSeq || + (!runEnded(run) && run.state !== "waiting_user") + ) { + try { + await readEvents(view, signal, () => { + retry = 0; + onConnection(""); + update(); + }); + } catch (error) { + // A completed run can expire between list and replay. Keep any + // already rendered output and continue observing the current task. + if ( + !( + error instanceof DevelopmentRequestError && + error.status === 404 && + runEnded(run) + ) + ) + throw error; + } + } + } + retry = 0; + onConnection(""); + update(); + await pause(1500); + } catch (error) { + if (signal.aborted) return; + if ( + error instanceof DevelopmentRequestError && + [401, 403, 404].includes(error.status) + ) + throw error; + onConnection(adkT("developmentRuns.reconnecting")); + await delay(Math.min(1000 * 2 ** retry++, 15_000), signal); + } + } +} diff --git a/frontend/src/adk/sandbox.ts b/frontend/src/adk/sandbox.ts index d9d4a080e..c4fdb9183 100644 --- a/frontend/src/adk/sandbox.ts +++ b/frontend/src/adk/sandbox.ts @@ -148,6 +148,7 @@ export interface SandboxUploadedFile { } export interface SandboxTokenUsage { + cacheWriteInputTokens?: number; totalTokens: number; inputTokens: number; cachedInputTokens: number; @@ -529,6 +530,12 @@ interface SandboxErrorPayload { } interface SandboxStreamPayload { + itemType?: unknown; + phase?: unknown; + durationMs?: unknown; + snapshot?: unknown; + plan?: unknown; + items?: unknown; id?: unknown; kind?: unknown; status?: unknown; @@ -556,7 +563,7 @@ interface SandboxStreamPayload { retryable?: unknown; } -function sandboxHeaders(headers?: HeadersInit): Headers { +export function sandboxHeaders(headers?: HeadersInit): Headers { const next = withLocaleHeaders(headers); if (!next.has("Accept")) next.set("Accept", "application/json"); return next; @@ -954,24 +961,17 @@ function parseApproval(payload: SandboxStreamPayload): SandboxApproval | null { }; } -async function parseSandboxStream( - response: Response, - options: SandboxRequestOptions = {}, -): Promise { - if (!response.body) throw new Error(adkT("sandbox.emptyConversationResponse")); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; +export function createSandboxProjection(options: SandboxRequestOptions = {}) { let reply = ""; const blocks: Block[] = []; const activityIndexes = new Map(); + const textIndexes = new Map(); let progressBlock: Extract | undefined; let latestUsage: SandboxTokenUsageUpdate | undefined; function emitBlocks(): void { const visible = progressBlock ? [...blocks, progressBlock] : blocks; - options.onBlocks?.(visible.map((block) => ({ ...block }))); + options.onBlocks?.([...visible]); } function appendReply(text: string): void { @@ -979,7 +979,7 @@ async function parseSandboxStream( const last = blocks[blocks.length - 1]; const lastIndex = blocks.length - 1; const activityBacked = [...activityIndexes.values()].includes(lastIndex); - if (last?.kind === "text" && !activityBacked) last.text += text; + if (last?.kind === "text" && !activityBacked) blocks[lastIndex] = { ...last, text: last.text + text }; else blocks.push({ kind: "text", text }); emitBlocks(); } @@ -990,13 +990,12 @@ async function parseSandboxStream( (payload.kind !== "thinking" && payload.kind !== "commentary" && payload.kind !== "tool") || - (payload.status !== "running" && payload.status !== "done") + (payload.status !== "running" && payload.status !== "done" && payload.status !== "error") ) return; - const done = payload.status === "done"; + const done = payload.status !== "running"; let block: Block; if (payload.kind === "thinking") { - if (typeof payload.text !== "string" || !payload.text) return; - block = { kind: "thinking", text: payload.text, done }; + block = { kind: "thinking", text: typeof payload.text === "string" ? payload.text : "", done }; } else if (payload.kind === "commentary") { if (typeof payload.text !== "string" || !payload.text) return; block = { kind: "text", text: payload.text }; @@ -1007,9 +1006,14 @@ async function parseSandboxStream( name: payload.name, args: payload.args, response: payload.response, + status: payload.status === "error" ? "failed" : done ? "completed" : "running", done, }; } + block = { ...block, id: payload.id, + ...(typeof payload.itemType === "string" ? { itemType: payload.itemType } : {}), + ...(typeof payload.phase === "string" ? { phase: payload.phase } : {}), + ...(typeof payload.durationMs === "number" ? { durationMs: payload.durationMs } : {}) }; const existing = activityIndexes.get(payload.id); if (existing === undefined) { activityIndexes.set(payload.id, blocks.length); @@ -1045,14 +1049,41 @@ async function parseSandboxStream( publicMessage: message, }); } - if (event === "progress" && typeof payload.text === "string" && payload.text) { - progressBlock = { - kind: "progress", - text: payload.text, - }; + if (event === "progress" && typeof payload.text === "string") { + progressBlock = payload.text ? { kind: "progress", text: payload.text } : undefined; emitBlocks(); } + if (["activity", "delta", "tool_output", "tool_progress", "plan", "diff"].includes(event)) progressBlock = undefined; if (event === "activity") applyActivity(payload); + if ((event === "tool_output" || event === "tool_progress") && typeof payload.id === "string") { + const index = activityIndexes.get(payload.id); + const block = index === undefined ? undefined : blocks[index]; + if (block?.kind === "tool" && index !== undefined && typeof payload.text === "string") { + const response = recordOf(block.response) || {}; + blocks[index] = event === "tool_progress" + ? { ...block, progressText: payload.text } + : { ...block, response: { ...response, output: (payload.snapshot ? "" : String(response.output || "")) + payload.text } }; + emitBlocks(); + } + } + if ((event === "plan" || event === "diff") && typeof payload.id === "string") { + const text = typeof payload.text === "string" ? payload.text : ""; + const items = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.plan) ? payload.plan : []; + const block: Block = event === "diff" + ? { kind: "diff", id: payload.id, text, done: payload.status === "done" } + : { kind: "plan", id: payload.id, title: adkT("developmentRuns.plan"), summary: text, + done: payload.status === "done", items: items.flatMap((value) => { + const item = recordOf(value); + if (!item || typeof (item.text ?? item.step) !== "string") return []; + const status = item.status === "inProgress" ? "in_progress" : item.status; + return [{ text: String(item.text ?? item.step), status: status === "completed" || status === "failed" || status === "in_progress" ? status : "pending" }]; + }) }; + const index = activityIndexes.get(payload.id); + if (index === undefined) { activityIndexes.set(payload.id, blocks.length); blocks.push(block); } + else blocks[index] = block; + emitBlocks(); + } + if (event === "development.source_ready" || event === "development.succeeded") { const eventPayload = recordOf(payload.payload); const eventData = recordOf(eventPayload?.delivery); @@ -1130,36 +1161,66 @@ async function parseSandboxStream( options.onApprovalResolved?.(payload.approvalId); } if (event === "delta" && typeof payload.text === "string") { - appendReply(payload.text); + if (typeof payload.id === "string" && payload.id) { + const existing = textIndexes.get(payload.id); + const previous = existing === undefined ? undefined : blocks[existing]; + const snapshot = recordOf(payload)?.snapshot === true; + if (previous?.kind === "text") { + blocks[existing!] = { ...previous, + phase: typeof payload.phase === "string" && payload.phase ? payload.phase : previous.phase, + text: snapshot ? (previous.text.startsWith(payload.text) ? previous.text : payload.text) : previous.text + payload.text }; + } else { + textIndexes.set(payload.id, blocks.length); + blocks.push({ kind: "text", text: payload.text, id: payload.id, + itemType: typeof payload.itemType === "string" ? payload.itemType : undefined, + phase: typeof payload.phase === "string" ? payload.phase : undefined }); + } + reply = blocks.filter((block) => block.kind === "text").map((block) => block.text).join(""); + emitBlocks(); + } else appendReply(payload.text); } if (event === "done" && !reply && typeof payload.text === "string") { appendReply(payload.text); } - if (event === "done" && progressBlock) { + if (event === "done") { + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + if (block.kind === "thinking" || block.kind === "plan" || block.kind === "diff") blocks[i] = { ...block, done: true }; + if (block.kind === "tool" && !block.done) blocks[i] = { ...block, done: true, status: block.status === "failed" ? "failed" : "completed" }; + } progressBlock = undefined; emitBlocks(); } } + return { + consumeFrame, + result: (): SandboxReply => ({ text: reply, blocks: blocks.map((block) => ({ ...block })), ...(latestUsage ? { usage: latestUsage } : {}) }), + }; +} + +async function parseSandboxStream( + response: Response, + options: SandboxRequestOptions = {}, +): Promise { + if (!response.body) throw new Error(adkT("sandbox.emptyConversationResponse")); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const projection = createSandboxProjection(options); while (true) { const { done, value } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); const frames = buffer.split(/\r?\n\r?\n/); buffer = frames.pop() ?? ""; - frames.forEach(consumeFrame); + frames.forEach(projection.consumeFrame); if (done) break; } - if (buffer.trim()) consumeFrame(buffer); - if (progressBlock) { - progressBlock = undefined; - emitBlocks(); - } - if (blocks.length === 0) throw new Error(adkT("sandbox.emptyReply")); - return { - text: reply, - blocks, - ...(latestUsage ? { usage: latestUsage } : {}), - }; + if (buffer.trim()) projection.consumeFrame(buffer); + projection.consumeFrame("event: done\ndata: {}"); + const reply = projection.result(); + if (reply.blocks.length === 0) throw new Error(adkT("sandbox.emptyReply")); + return reply; } async function sandboxJson( diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts index 8e26f177a..76b823ddb 100644 --- a/frontend/src/blocks.ts +++ b/frontend/src/blocks.ts @@ -92,7 +92,29 @@ export interface CodexSandboxActivity { items: Array<{ id: string; block: Block }>; } -export type Block = +export interface DevelopmentTurnMetrics { + turnId: string; + status: string; + model?: string; + durationMs?: number; + startedAt?: number; + completedAt?: number; + usage?: Partial; + usageIncomplete?: boolean; + toolCalls: number; + toolDurationMs?: number; + toolDurationComplete: boolean; +} + +export type Block = { + id?: string; + itemType?: string; + turnId?: string; + phase?: string; + durationMs?: number; +} & ( + | { kind: "turn-summary"; value: DevelopmentTurnMetrics } + | { kind: "diff"; text: string; done: boolean } | { kind: "progress"; text: string } | { kind: "thinking"; text: string; done: boolean } | { kind: "text"; text: string } @@ -106,6 +128,7 @@ export type Block = status?: "running" | "completed" | "failed"; defaultOpen?: boolean; codexActivity?: CodexSandboxActivity; + progressText?: string; } | { kind: "plan"; @@ -137,7 +160,7 @@ export type Block = authUri?: string; authConfig: unknown; done: boolean; - }; + }); /** Accumulator for one assistant turn. `liveStart` marks where the current * streaming-preview blocks begin (everything before it is finalized). */ diff --git a/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx b/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx index 229778b57..273713a5e 100644 --- a/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx +++ b/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx @@ -1,3 +1,4 @@ +import { DevelopmentTurnSummary } from "../../../create/DevelopmentTurnSummary"; import type { ReactNode } from "react"; import type { AttachmentView, Block, Turn } from "../../../blocks"; import type { A2uiAction, A2uiComponent, A2uiMessage } from "../../../a2ui/types"; @@ -193,6 +194,8 @@ export function fromStudioTurns(turns: readonly Turn[], options: StudioConversat const custom = options.renderBlock?.(block, turn); if (custom !== undefined) return { id, type: "custom", content: custom }; switch (block.kind) { + case "turn-summary": return {id, type: "custom", content: }; + case "diff": return { id, type: "custom", content: }; case "text": return { id, type: "markdown", text: block.text }; case "thinking": return { id, type: "reasoning", title: "思考过程", status: block.done ? "complete" : "running", content: block.text }; case "progress": return { id, type: "reasoning", title: block.text, status: turn.meta?.streaming ? "running" : "complete", content: block.text }; diff --git a/frontend/src/create/DevelopmentItemIcon.tsx b/frontend/src/create/DevelopmentItemIcon.tsx new file mode 100644 index 000000000..2d590b71e --- /dev/null +++ b/frontend/src/create/DevelopmentItemIcon.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from "react"; +import { AgentKitLogoIcon } from "../ui/icons/AgentKitLogoIcon"; + +/** Native development items share the same 18px, repository-drawn icon geometry. */ +export function DevelopmentItemIcon({kind, ...props}: SVGProps & {kind: "tool" | "plan" | "diff" | "thinking"}) { + if (kind === "thinking") return ; + return ; +} diff --git a/frontend/src/create/DevelopmentProcess.css b/frontend/src/create/DevelopmentProcess.css new file mode 100644 index 000000000..02b2372c7 --- /dev/null +++ b/frontend/src/create/DevelopmentProcess.css @@ -0,0 +1,48 @@ +.development-process { width: 100%; min-width: 0; margin: 8px 0; color: hsl(var(--muted-foreground)); } +.development-process__toggle { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 32px; padding: 4px 0; background: none; border: 0; color: inherit; cursor: pointer; text-align: left; font: inherit; font-size: 13px; } +.development-process__toggle:hover { color: hsl(var(--foreground)); } +.development-process__toggle:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 2px; border-radius: 4px; } +.development-process__toggle .tool-chevron { flex: 0 0 14px; } +.development-process__title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.development-process__duration { flex-shrink: 0; font-size: 12px; } +.development-process__items { padding: 4px 0 4px 20px; min-width: 0; } +.development-process__items[hidden] { display: none; } +.development-process__status { min-height: 32px; display: flex; align-items: center; font-size: 13px; color: hsl(var(--muted-foreground)); } +.development-process__items pre, .development-diff pre { max-height: 320px; overflow: auto; white-space: pre; } +.development-diff { min-width: 0; font-size: 13px; margin: 8px 0; } +.development-diff summary { cursor: pointer; min-height: 32px; } +.development-diff pre { padding: 12px; background: hsl(var(--muted)); border-radius: 6px; font-size: 12px; } +.development-tool-meta { font-size: 12px; color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; } +.development-preparation { display: flex; flex-direction: column; min-height: 0; height: 100%; } +.development-preparation .transcript { flex: 1; } +.development-preparation-source { display: contents; } +.development-preparation-source[hidden] { display: none; } +.development-preparation__status { display: flex; align-items: center; gap: 12px; max-width: 100%; color: hsl(var(--muted-foreground)); font-size: 13px; } +.development-preparation__message { display: grid; min-width: 0; } +.development-preparation__message > span { grid-area: 1 / 1; } +.development-preparation__message > [aria-hidden="true"] { visibility: hidden; } +.development-preparation__spinner { flex-shrink: 0; } +.development-preparation__cancel { flex-shrink: 0; min-height: 32px; padding: 4px 8px; border: 0; border-radius: 6px; background: transparent; color: inherit; font: inherit; cursor: pointer; } +.development-preparation__cancel:hover { color: hsl(var(--foreground)); background: hsl(var(--accent)); } +.development-preparation__cancel:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { + .development-preparation__spinner { animation: none; } +} + +.development-tool-failure { color: hsl(var(--destructive)); font-size: 12px; flex-shrink: 0; } + +.development-process__items .think-head, .development-process__items .tool-head { display: flex; max-width: 100%; text-align: left; } +.development-process__items .think-label, .development-process__items .tool-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } +.development-tool-meta { flex-shrink: 0; white-space: nowrap; } +.development-process__toggle { flex-wrap: wrap; } +.development-process__title { flex: 1; text-align: left; } +.development-diff summary { display: flex; align-items: center; gap: 8px; padding: 3px 7px 3px 3px; list-style: none; } +.development-diff summary::-webkit-details-marker { display: none; } +.development-diff[open] > summary .tool-chevron { transform: rotate(90deg); } + +.development-process__status { gap: 8px; } +.development-process__items .plan-head { display: flex; width: 100%; min-height: 32px; gap: 8px; padding: 3px 7px 3px 3px; text-align: left; } +.development-process__items .plan-icon { width: 20px; height: 26px; flex: 0 0 20px; display: grid; place-items: center; } +.development-process__items .plan-icon > svg { width: 18px; height: 18px; } +.development-process__items .plan-title { font-size: 13px; font-weight: 400; } +.development-process__items .plan-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/frontend/src/create/DevelopmentProcess.tsx b/frontend/src/create/DevelopmentProcess.tsx new file mode 100644 index 000000000..b6d52b9da --- /dev/null +++ b/frontend/src/create/DevelopmentProcess.tsx @@ -0,0 +1,50 @@ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { Block } from "../blocks"; +import { DevelopmentItemIcon } from "./DevelopmentItemIcon"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import { ToolDisclosureIcon } from "../ui/builtin-tools/icons"; +import { developmentProcessGroups, developmentToolLabel, formatDevelopmentDuration } from "./developmentPresentation"; +import "./DevelopmentProcess.css"; + +function ProcessGroup({ blocks, active, status, render }: { + blocks: Block[]; active: boolean; status: string; render: (blocks: Block[]) => ReactNode; +}) { + const { t } = useTranslation("adk"); + const [open, setOpen] = useState(false); + const running = [...blocks].reverse().find((block) => "done" in block && !block.done); + const tools = blocks.filter(block => block.kind === "tool"); + const measured = tools.filter(block => block.durationMs != null); + const duration = measured.reduce((sum, block) => sum + (block.durationMs || 0), 0); + let title = t("developmentRuns.processSummary", { count: blocks.length }); + if (active) { + title = status || (running?.kind === "thinking" ? t("developmentRuns.thinking") + : running?.kind === "tool" ? developmentToolLabel(running) + : running?.kind === "plan" ? t("developmentRuns.plan") + : running?.kind === "diff" ? t("developmentRuns.diff") : t("developmentRuns.processing")); + } + return
+ + +
; +} +export function DevelopmentProcess({ blocks, active, status = "", render }: { + blocks: Block[]; active: boolean; status?: string; render: (blocks: Block[]) => ReactNode; +}) { + const { t } = useTranslation("adk"); + const groups = developmentProcessGroups(blocks); + const last = groups[groups.length - 1]; + const progress = [...blocks].reverse().find((block) => block.kind === "progress"); + const liveStatus = status || (progress?.kind === "progress" ? progress.text : ""); + return <> + {groups.map((group) => group.process + ? + :
{render(group.blocks)}
)} + {active && !last?.process &&
{liveStatus || t("developmentRuns.processing")}
} + ; +} diff --git a/frontend/src/create/DevelopmentTaskList.tsx b/frontend/src/create/DevelopmentTaskList.tsx new file mode 100644 index 000000000..efb0b1e71 --- /dev/null +++ b/frontend/src/create/DevelopmentTaskList.tsx @@ -0,0 +1,202 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@openai/apps-sdk-ui/components/Tooltip"; +import { localeCompatibleBackendText } from "../i18n/locales"; +import { SourceRefreshIcon } from "../ui/icons/SourceWorkspaceIcons"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import type { DevelopmentTaskSnapshot } from "./DevelopmentTaskNotice"; + +interface Props { + ownerId: string; + snapshot?: DevelopmentTaskSnapshot; + disabled: boolean; + onRefresh: () => void; + onOpen: (sessionId: string, signal: AbortSignal) => Promise; +} + +function TaskIcon() { + return ( + + ); +} + +function TaskArrowIcon() { + return ( + + ); +} + +export function DevelopmentTaskList({ + ownerId, + snapshot, + disabled, + onRefresh, + onOpen, +}: Props) { + const { t, i18n } = useTranslation("create"); + const [openingId, setOpeningId] = useState(""); + const [openError, setOpenError] = useState(""); + const opening = useRef(null); + const state = snapshot?.ownerId === ownerId ? snapshot : undefined; + const runs = [...(state?.runs ?? [])].sort( + (a, b) => b.createdAt - a.createdAt, + ); + const loading = state?.loading ?? true; + const locale = i18n.resolvedLanguage || i18n.language; + const error = + localeCompatibleBackendText(state?.error, locale) || + (state?.error ? t("intelligent.tasks.loadError") : ""); + + useEffect(() => { + setOpeningId(""); + setOpenError(""); + return () => { + opening.current?.abort(); + opening.current = null; + }; + }, [ownerId, disabled]); + + async function open(sessionId: string, runId: string) { + if (opening.current || disabled) return; + const controller = new AbortController(); + opening.current = controller; + setOpeningId(runId); + setOpenError(""); + try { + await onOpen(sessionId, controller.signal); + } catch (cause) { + if (controller.signal.aborted) return; + setOpenError( + localeCompatibleBackendText( + cause instanceof Error ? cause.message : "", + locale, + ) || t("intelligent.tasks.openError"), + ); + } finally { + if (opening.current === controller) { + opening.current = null; + setOpeningId(""); + } + } + } + + function startedAt(value: number) { + const date = new Date(value * 1000); + if (!Number.isFinite(date.getTime())) return ""; + return t("intelligent.tasks.startedAt", { + time: new Intl.DateTimeFormat(locale, { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date), + }); + } + + return ( +
+
+

{t("intelligent.tasks.title")}

+ {runs.length > 0 ? ( + {runs.length} + ) : null} + +
+

{t("intelligent.tasks.hint")}

+ {error ? ( +
+ {error} + +
+ ) : null} + {openError ? ( +

+ {openError} +

+ ) : null} + {runs.length ? ( +
    + {runs.map((run) => ( +
  • + + + +
  • + ))} +
+ ) : loading && !error ? ( +
+ {t("intelligent.tasks.loading")} +
+ ) : !error ? ( +
+ + + + {t("intelligent.tasks.empty")} +

{t("intelligent.tasks.emptyHint")}

+
+ ) : null} +
+ ); +} diff --git a/frontend/src/create/DevelopmentTaskNotice.tsx b/frontend/src/create/DevelopmentTaskNotice.tsx new file mode 100644 index 000000000..0b59ee410 --- /dev/null +++ b/frontend/src/create/DevelopmentTaskNotice.tsx @@ -0,0 +1,189 @@ +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + developmentRuns, + DevelopmentRequestError, + runEnded, + type DevelopmentRun, +} from "../adk/developmentRuns"; +import { Button } from "../components/primitives/Button"; +import { ToastProvider, useToast } from "../components/primitives/Toast"; + +interface Props { + ownerId: string; + sessionId: string; + onOpen: (session: string, signal: AbortSignal) => Promise; + hideNotices?: boolean; + refreshKey?: number; + onUpdate?: (snapshot: DevelopmentTaskSnapshot) => void; +} + +export interface DevelopmentTaskSnapshot { + ownerId: string; + runs: DevelopmentRun[]; + loading: boolean; + error: string; +} + +function TaskMonitor({ ownerId, sessionId, onOpen, hideNotices, refreshKey, onUpdate }: Props) { + const { t } = useTranslation("sandbox"); + const toast = useToast(); + const known = useRef(new Map()); + const shown = useRef(new Map()); + const refresh = useRef<(() => void) | null>(null); + const current = useRef({ sessionId, onOpen, toast, t, hideNotices, onUpdate }); + current.current = { sessionId, onOpen, toast, t, hideNotices, onUpdate }; + useEffect(() => { + if (hideNotices) { + current.current.toast.dismiss(); + shown.current.clear(); + return; + } + for (const run of known.current.values()) { + if (run.sessionId === sessionId) { + current.current.toast.dismiss(run.runId); + shown.current.delete(run.runId); + } + } + }, [sessionId, hideNotices]); + useEffect(() => { refresh.current?.(); }, [refreshKey]); + useEffect(() => { + if (!ownerId) return; + const controller = new AbortController(); + const knownRuns = known.current; + const shownStates = shown.current; + const hidden = new Set(); + let timer: ReturnType; + let inFlight = false; + let activeRuns: DevelopmentRun[] = []; + let lastError = ""; + const publish = (loading: boolean, error = lastError) => { + lastError = error; + if (!controller.signal.aborted) + current.current.onUpdate?.({ ownerId, runs: activeRuns, loading, error }); + }; + const show = (run: DevelopmentRun) => { + const { toast, t, sessionId, onOpen } = current.current; + if (controller.signal.aborted) return; + if (current.current.hideNotices || run.sessionId === sessionId) { + toast.dismiss(run.runId); + shownStates.delete(run.runId); + return; + } + if (hidden.has(run.runId) || shownStates.get(run.runId) === run.state) return; + shownStates.set(run.runId, run.state); + toast.add({ + id: run.runId, + duration: 0, + title: t(`taskNotice.${run.state}`), + description: run.message.slice(0, 80), + variant: + run.state === "succeeded" + ? "success" + : run.state === "failed" + ? "error" + : "info", + closeLabel: t("taskNotice.hide"), + onClose: () => hidden.add(run.runId), + action: ( + + ), + }); + }; + const poll = async (manual = false) => { + if (inFlight || controller.signal.aborted) return; + clearTimeout(timer); + inFlight = true; + if (manual) publish(true); + try { + const active = await developmentRuns.active(controller.signal); + if (controller.signal.aborted) return; + activeRuns = active; + publish(false, ""); + const ids = new Set(active.map((run) => run.runId)); + for (const previous of knownRuns.values()) { + if (!ids.has(previous.runId) && !runEnded(previous)) { + let ended: DevelopmentRun; + try { + ended = await developmentRuns.get( + previous.runId, + controller.signal, + ); + } catch (error) { + if ( + error instanceof DevelopmentRequestError && + error.status === 404 + ) { + knownRuns.delete(previous.runId); + shownStates.delete(previous.runId); + current.current.toast.dismiss(previous.runId); + continue; + } + throw error; + } + if (controller.signal.aborted) return; + knownRuns.set(ended.runId, ended); + show(ended); + } + } + for (const run of active) { + knownRuns.set(run.runId, run); + show(run); + } + current.current.toast.dismiss("task-connection"); + } catch (error) { + if (controller.signal.aborted) return; + if (error instanceof DevelopmentRequestError && [401, 403].includes(error.status)) { + activeRuns = []; + knownRuns.clear(); + shownStates.clear(); + current.current.toast.dismiss(); + } + publish(false, error instanceof Error ? error.message : current.current.t("taskNotice.reconnecting")); + if (!controller.signal.aborted && knownRuns.size && !current.current.hideNotices) + current.current.toast.add({ + id: "task-connection", + duration: 0, + description: current.current.t("taskNotice.reconnecting"), + }); + } finally { + inFlight = false; + if (!controller.signal.aborted) timer = setTimeout(poll, 3000); + } + }; + refresh.current = () => { void poll(true); }; + publish(true); + void poll(); + return () => { + controller.abort(); + clearTimeout(timer); + refresh.current = null; + current.current.toast.dismiss(); + }; + }, [ownerId]); + return null; +} + +export function DevelopmentTaskNotice(props: Props) { + const { t } = useTranslation("sandbox"); + return ( + + + + ); +} diff --git a/frontend/src/create/DevelopmentTurnSummary.css b/frontend/src/create/DevelopmentTurnSummary.css new file mode 100644 index 000000000..0c1a5cb5b --- /dev/null +++ b/frontend/src/create/DevelopmentTurnSummary.css @@ -0,0 +1,16 @@ +.development-turn-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 8px 0; min-width: 0; font-size: 12px; color: hsl(var(--muted-foreground)); } +.development-turn-summary > span { white-space: nowrap; } +.development-token-trigger { display: inline-flex; align-items: center; gap: 5px; min-height: 26px; padding: 2px 5px; border: 0; border-radius: 4px; background: transparent; color: inherit; font: inherit; cursor: pointer; white-space: nowrap; } +.development-token-trigger:hover, .development-token-trigger[data-popup-open] { color: hsl(var(--foreground)); background: hsl(var(--muted)); } +.development-token-value { color: hsl(var(--foreground)); font-weight: 500; font-variant-numeric: tabular-nums; } +.development-token-chevron { width: 12px; height: 12px; flex-shrink: 0; transform: rotate(90deg); } +.development-token-trigger[data-popup-open] .development-token-chevron { transform: rotate(-90deg); } +.development-token-trigger:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 2px; } +.development-token-positioner { z-index: 100; } +.development-token-popup { width: min(320px, calc(100vw - 32px)); max-height: var(--available-height); overflow: auto; padding: 16px; background: hsl(var(--panel)); color: hsl(var(--foreground)); border: 1px solid hsl(var(--border)); border-radius: 8px; box-shadow: 0 6px 24px hsl(0 0% 0% / 0.16); font-size: 12px; } +.development-token-title { margin: 0 0 12px; font-size: 13px; font-weight: 500; } +.development-token-popup dl { display: grid; gap: 8px; margin: 0; } +.development-token-popup dl > div { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 12px; } +.development-token-popup dt { color: hsl(var(--muted-foreground)); } +.development-token-popup dd { margin: 0; text-align: right; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.development-token-note { margin: 12px 0 0; color: hsl(var(--muted-foreground)); font-size: 12px; line-height: 1.5; } diff --git a/frontend/src/create/DevelopmentTurnSummary.tsx b/frontend/src/create/DevelopmentTurnSummary.tsx new file mode 100644 index 000000000..f1cf28bda --- /dev/null +++ b/frontend/src/create/DevelopmentTurnSummary.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Popover } from "@base-ui/react/popover"; +import type { DevelopmentTurnMetrics } from "../blocks"; +import { ToolDisclosureIcon } from "../ui/builtin-tools/icons"; +import { formatDevelopmentDuration } from "./developmentPresentation"; +import "./DevelopmentTurnSummary.css"; + +export function DevelopmentTurnSummary({value}: {value: DevelopmentTurnMetrics}) { + const {t, i18n} = useTranslation("adk"); + const [open, setOpen] = useState(false); + const format = (n: number | undefined) => n == null ? t("developmentRuns.notReported") : n.toLocaleString(i18n.resolvedLanguage || i18n.language); + const time = formatDevelopmentDuration; + const usage = value.usage; + const uncached = usage?.inputTokens != null && usage.cachedInputTokens != null && usage.cachedInputTokens <= usage.inputTokens + ? usage.inputTokens - usage.cachedInputTokens : undefined; + const hitRate = usage?.inputTokens && usage.cachedInputTokens != null && uncached != null + ? `${(usage.cachedInputTokens / usage.inputTokens * 100).toFixed(1)}%` : undefined; + const entries = [ + ["inputTokens", usage?.inputTokens], ["cachedInputTokens", usage?.cachedInputTokens], + ["uncachedInputTokens", uncached], ["cacheWriteInputTokens", usage?.cacheWriteInputTokens], + ["outputTokens", usage?.outputTokens], ["reasoningOutputTokens", usage?.reasoningOutputTokens], + ] as const; + return
+ {t(`developmentRuns.turnStatus.${value.status}`)} + {t("developmentRuns.toolCalls", {count: value.toolCalls})} + {t("developmentRuns.turnDuration", {duration: time(value.durationMs)})} + {t(value.toolDurationComplete ? "developmentRuns.toolDuration" : "developmentRuns.toolDurationPartial", {duration: time(value.toolDurationMs)})} + + { if (event.currentTarget.matches(":focus-visible")) setOpen(true); }}> + Tokens{format(usage?.totalTokens)} + {value.usageIncomplete && · {t("developmentRuns.partial")}} + + + + + + {t("developmentRuns.tokenDetails")} +
+
{t("developmentRuns.model")}
{value.model || t("developmentRuns.notReported")}
+
{t("developmentRuns.totalTokens")}
{format(usage?.totalTokens)}
+ {entries.map(([key, count]) =>
{t(`developmentRuns.${key}`)}
{format(count)}
)} +
{t("developmentRuns.cacheHitRate")}
{hitRate || t("developmentRuns.notReported")}
+
+ {t("developmentRuns.tokenHelp")}{value.usageIncomplete ? ` ${t("developmentRuns.partialHelp")}` : ""} +
+
+
+
+
; +} diff --git a/frontend/src/create/IntelligentCreate.css b/frontend/src/create/IntelligentCreate.css index 517d08847..5778bd7a0 100644 --- a/frontend/src/create/IntelligentCreate.css +++ b/frontend/src/create/IntelligentCreate.css @@ -5,15 +5,14 @@ min-height: 0; background: hsl(var(--canvas)); overflow: hidden; + container: intelligent-home / inline-size; } .ic-header { display: flex; - align-items: flex-start; - gap: 12px; - padding: 20px 24px; - border-bottom: 1px solid hsl(var(--border)); - background: hsl(var(--panel)); + align-items: center; + gap: 16px; + padding: 0; } .ic-header h1, @@ -47,9 +46,9 @@ .ic-content { display: grid; - gap: 16px; + gap: 24px; width: 100%; - max-width: 760px; + max-width: 1120px; margin: 0 auto; } @@ -592,7 +591,7 @@ .ic-notice.is-error { color: hsl(var(--destructive)); } @media (max-width: 640px) { - .ic-header { padding: 16px; } + .ic-header { align-items: flex-start; } .ic-main { padding: 20px 16px; } .ic-panel { padding: 18px; } .ic-composer { grid-template-rows: minmax(104px, auto) auto; } @@ -614,3 +613,59 @@ @media (prefers-reduced-motion: reduce) { .ic-root * { transition: none !important; } } + + +.ic-workspace { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, 1fr); + align-items: stretch; + gap: 20px; + min-width: 0; +} +.ic-workspace > .ic-goal-panel { display: flex; flex-direction: column; min-width: 0; } +.ic-workspace .ic-composer { flex: 1; } +.ic-tasks-panel { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.ic-tasks-heading { display: flex; align-items: center; gap: 8px; min-height: 40px; } +.ic-tasks-heading h2 { font-size: 17px; font-weight: 600; line-height: 1.3; } +.ic-task-count { padding: 2px 7px; border-radius: 999px; background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); font-size: 12px; } +.ic-tasks-hint { margin-top: 12px !important; font-size: 12.5px; line-height: 1.5; color: hsl(var(--muted-foreground)); } +.ic-tasks-panel > .ic-inline-error { margin-top: 12px; } +.ic-task-list { display: grid; align-content: start; gap: 8px; min-height: 0; max-height: 360px; overflow-y: auto; scrollbar-width: thin; margin: 16px -2px -2px; padding: 2px; list-style: none; } +.ic-task { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; + padding: 12px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 140ms ease, border-color 140ms ease; +} +.ic-task:hover:not(:disabled) { background: hsl(var(--muted) / .55); border-color: hsl(var(--foreground) / .24); } +.ic-task:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; } +.ic-task:disabled { opacity: .55; cursor: not-allowed; } +.ic-task[aria-busy="true"] { opacity: 1; cursor: progress; } +.ic-task-status { display: inline-flex; align-items: center; gap: 6px; color: hsl(var(--muted-foreground)); font-size: 12px; line-height: 1.5; } +.ic-task-status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; } +.ic-task-status.is-running { color: hsl(150 50% 30%); } +.ic-task-status.is-waiting_user { color: hsl(32 75% 36%); } +.ic-task-goal { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere; font-size: 13px; font-weight: 500; line-height: 1.55; } +.ic-task-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; width: 100%; color: hsl(var(--muted-foreground)); font-size: 12px; } +.ic-task-action { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; color: hsl(var(--foreground)); white-space: nowrap; } +.ic-task-action svg { width: 16px; height: 16px; flex-shrink: 0; } +.ic-task-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; min-height: 180px; padding: 24px 0; color: hsl(var(--muted-foreground)); font-size: 12.5px; text-align: center; } +.ic-task-empty strong { font-size: 13px; font-weight: 500; color: hsl(var(--foreground)); } +.ic-task-empty-icon { display: inline-grid; place-items: center; width: 40px; height: 40px; margin-bottom: 4px; border-radius: 9px; background: hsl(var(--muted)); } +.ic-task-empty-icon svg { width: 20px; height: 20px; } + +@container intelligent-home (max-width: 900px) { + .ic-workspace { grid-template-columns: minmax(0, 1fr); } + .ic-tasks-panel:has(.ic-task-list) { grid-row: 1; } + .ic-task-empty { min-height: 120px; } +} diff --git a/frontend/src/create/IntelligentCreate.tsx b/frontend/src/create/IntelligentCreate.tsx index 2a99e268c..ae97bf2c0 100644 --- a/frontend/src/create/IntelligentCreate.tsx +++ b/frontend/src/create/IntelligentCreate.tsx @@ -13,6 +13,8 @@ import { } from "../ui/new-chat-modes/NewChatCompactSelect"; import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; import { IntelligentProjectLibrary } from "./IntelligentProjectLibrary"; +import { DevelopmentTaskList } from "./DevelopmentTaskList"; +import type { DevelopmentTaskSnapshot } from "./DevelopmentTaskNotice"; import "./IntelligentCreate.css"; function IntelligentCreateIcon() { @@ -100,6 +102,10 @@ function IntelligentModelSelect({ } export interface IntelligentCreateProps { + ownerId: string; + taskSnapshot?: DevelopmentTaskSnapshot; + onRefreshTasks: () => void; + onOpenTask: (sessionId: string, signal: AbortSignal) => Promise; capabilities: IntelligentDevelopmentCapabilities | null; loading: boolean; preparationStage: IntelligentPreparationStage | null; @@ -125,12 +131,14 @@ export function IntelligentGoalPanel({ onCreate, baseVersion, onClearBaseVersion, + autoFocus = true, }: Pick< IntelligentCreateProps, "capabilities" | "loading" | "preparationStage" | "error" | "onCancel" | "onCreate" > & { baseVersion?: IntelligentCreateBaseVersion; onClearBaseVersion?: () => void; + autoFocus?: boolean; }) { const { t, i18n } = useTranslation("create"); const [goal, setGoal] = useState(""); @@ -230,8 +238,9 @@ export function IntelligentGoalPanel({ useEffect(() => { setGoal(""); - window.requestAnimationFrame(() => goalInputRef.current?.focus()); - }, [baseVersion?.versionId]); + if (autoFocus || baseVersion?.versionId) + window.requestAnimationFrame(() => goalInputRef.current?.focus()); + }, [baseVersion?.versionId, autoFocus]); function changeModel(modelId: string) { const value = modelId.trim(); @@ -301,7 +310,7 @@ export function IntelligentGoalPanel({ : t("intelligent.goal.placeholder")} rows={6} disabled={loading || creating || unavailable} - autoFocus + autoFocus={autoFocus} />
@@ -367,6 +376,10 @@ export function IntelligentGoalPanel({ } export function IntelligentCreate({ + ownerId, + taskSnapshot, + onRefreshTasks, + onOpenTask, capabilities, loading, preparationStage, @@ -385,26 +398,37 @@ export function IntelligentCreate({ return (
-
- -
-

{t("intelligent.title")}

-

{t("intelligent.subtitle")}

-
-
-
- setBaseVersion(undefined)} - /> +
+ +
+

{t("intelligent.title")}

+

{t("intelligent.subtitle")}

+
+
+ +
+ setBaseVersion(undefined)} + /> + +
count > 0).map(([unit, count]) => + adkT(`developmentRuns.durationUnits.${unit}`, { value: count.toLocaleString(activeLocale(), { maximumFractionDigits: 1 }) }) + ).join(" "); +} + +const record = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const short = (value: unknown) => typeof value === "string" ? value.replace(/\s+/g, " ").trim().slice(0, 160) : ""; + +export function developmentToolLabel(block: Extract): string { + const args = record(block.args); + if (block.itemType === "dynamicToolCall" && block.name === "submit_build_result") return adkT("developmentRuns.submitResult"); + if (block.itemType === "commandExecution") { + const actions = Array.isArray(args.commandActions) ? args.commandActions.map(record) : []; + const types = new Set(actions.map(action => action.type)); + if (actions.length && types.size === 1) { + if (types.has("read")) return adkT("developmentRuns.read", { target: "Read project files" }); + if (types.has("listFiles")) return adkT("developmentRuns.listFiles", { target: "List directory" }); + if (types.has("search")) return adkT("developmentRuns.search", { target: "Search project files" }); + } + const command = typeof args.command === "string" ? args.command : ""; + const rules: [RegExp, string][] = [ + [/\b(?:pytest|vitest|jest)\b|\b(?:npm|pnpm|yarn) (?:run )?test\b/, "Run tests"], + [/\b(?:npm|pnpm|yarn) (?:ci|install|add)\b|\b(?:pip|pip3|uv pip) install\b|\buv sync\b/, "Install dependencies"], + [/\b(?:ruff|eslint|prettier|pyright|tsc)\b/, "Check code quality"], + [/\b(?:npm|pnpm|yarn) (?:run )?build\b|\bpython[^;]* -m build\b/, "Build project"], + [/\bgit (?:status|diff|log|show)\b/, "Inspect Git changes"], + [/\bgit (?:add|commit)\b/, "Save Git changes"], + [/\b(?:curl|wget)\b/, "Send HTTP request"], + [/\bcompileall\b/, "Check Python syntax"], + ]; + const summary = rules.find(([pattern]) => pattern.test(command))?.[1] || "Run shell command"; + return adkT("developmentRuns.command", { target: summary }); + } + if (block.itemType === "fileChange") { + const paths = Array.isArray(args.changes) ? args.changes.map((change) => short(record(change).path)).filter(Boolean) : []; + return adkT("developmentRuns.editFiles", { target: paths.join(", ") }); + } + if (block.itemType === "webSearch") return adkT("developmentRuns.webSearch", { target: short(args.query) }); + return block.name; +} +export const isDevelopmentProcess = (block: Block) => + ["tool", "thinking", "plan", "diff"].includes(block.kind); +export function developmentProcessGroups(blocks: Block[]): { id: string; process: boolean; blocks: Block[] }[] { + const groups: { id: string; process: boolean; blocks: Block[] }[] = []; + blocks.forEach((block, index) => { + if (block.kind === "progress") return; + const process = isDevelopmentProcess(block); + const last = groups[groups.length - 1]; + if (process && last?.process) last.blocks.push(block); + else groups.push({ id: block.id || `${block.kind}:${index}`, process, blocks: [block] }); + }); + return groups; +} diff --git a/frontend/src/create/useDevelopmentRun.ts b/frontend/src/create/useDevelopmentRun.ts new file mode 100644 index 000000000..67602de74 --- /dev/null +++ b/frontend/src/create/useDevelopmentRun.ts @@ -0,0 +1,241 @@ +import { useEffect, useRef, useState } from "react"; +import type { Turn } from "../blocks"; +import { adkT } from "../adk/i18n"; +import { + developmentRuns, + observeDevelopmentRuns, + runEnded, + type DevelopmentRun, +} from "../adk/developmentRuns"; + +export function useDevelopmentRun({ + sessionId, + ownerId, + onTurns, + onBusy, +}: { + sessionId: string; + ownerId: string; + onTurns: (turns: Turn[]) => void; + onBusy: (busy: boolean) => void; +}) { + const [run, setRun] = useState(null); + const [connection, setConnection] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [stopPending, setStopPending] = useState(false); + const callbacks = useRef({ onTurns, onBusy }); + callbacks.current = { onTurns, onBusy }; + const generation = useRef(0); + const serverTurns = useRef([]); + const optimisticTurns = useRef([]); + const seedRun = useRef<((run: DevelopmentRun) => void) | null>(null); + const publish = () => { + const confirmed = new Set(serverTurns.current.map((turn) => turn.meta?.localId)); + optimisticTurns.current = optimisticTurns.current.filter((turn) => !confirmed.has(turn.meta?.localId)); + callbacks.current.onTurns([...serverTurns.current, ...optimisticTurns.current]); + }; + const sending = useRef(false); + const stopIntent = useRef(false); + const retryInput = useRef<{ + session: string; + owner: string; + text: string; + id: string; + runId?: string; + stopRequested?: boolean; + } | null>(null); + const currentIdentity = useRef({ sessionId, ownerId }); + currentIdentity.current = { sessionId, ownerId }; + + useEffect(() => { + generation.current += 1; + setRun(null); + setError(""); + setConnection(""); + serverTurns.current = []; + seedRun.current = null; + const pending = retryInput.current; + if (pending?.session !== sessionId || pending.owner !== ownerId) { + optimisticTurns.current = []; + setStopPending(false); + stopIntent.current = false; + retryInput.current = null; + sending.current = false; + setSubmitting(false); + } + if (sessionId) publish(); + if (!sessionId || !ownerId) return; + const controller = new AbortController(); + const isCurrent = () => + !controller.signal.aborted && + currentIdentity.current.sessionId === sessionId && + currentIdentity.current.ownerId === ownerId; + void observeDevelopmentRuns( + sessionId, + controller.signal, + (turns, current) => { + if (!isCurrent()) return; + serverTurns.current = turns; + publish(); + setRun(current); + callbacks.current.onBusy(sending.current || Boolean(current && !runEnded(current))); + if (current && runEnded(current)) { + setStopPending(false); + stopIntent.current = false; + } + }, + (message) => { + if (isCurrent()) setConnection(message); + }, + (seed) => { if (isCurrent()) seedRun.current = seed; }, + ).catch((cause) => { + if (isCurrent()) + setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => controller.abort(); + }, [sessionId, ownerId]); + + function prepare(text: string, targetSession = sessionId): Turn[] { + const owner = currentIdentity.current.ownerId; + let input = retryInput.current; + if (!input || input.session !== targetSession || input.owner !== owner || input.text !== text) { + input = { session: targetSession, owner, text, id: crypto.randomUUID() }; + retryInput.current = input; + } + if (!optimisticTurns.current.some((turn) => turn.meta?.localId === `${input.id}:user`)) { + optimisticTurns.current.push( + { role: "user", blocks: [{ kind: "text", text }], meta: { localId: `${input.id}:user` } }, + { role: "assistant", blocks: [{ kind: "progress", text: adkT(run && !runEnded(run) ? "developmentRuns.processing" : "developmentRuns.preparing") }], meta: { localId: `${input.id}:assistant` } }, + ); + } + return [...optimisticTurns.current]; + } + + async function submit( + text: string, + targetSession = sessionId, + ): Promise { + if (!text.trim() || sending.current || stopIntent.current) return false; + sending.current = true; + setSubmitting(true); + setError(""); + const owner = currentIdentity.current.ownerId; + const active = () => + currentIdentity.current.ownerId === owner && + currentIdentity.current.sessionId === targetSession; + prepare(text, targetSession); + publish(); + callbacks.current.onBusy(true); + const previous = retryInput.current; + const input = + previous?.session === targetSession && + previous.owner === owner && + previous.text === text + ? previous + : { + session: targetSession, + owner, + text, + id: crypto.randomUUID(), + runId: undefined as string | undefined, + stopRequested: false, + }; + retryInput.current = input; + try { + const runs = await developmentRuns.list(targetSession); + const current = runs[runs.length - 1]; + if (!active()) return false; + if ( + !input.runId && + current && + !runEnded(current) && + current.requestId !== input.id + ) + input.runId = current.runId; + let accepted: DevelopmentRun; + if (input.runId) { + await developmentRuns.steer(input.runId, text, input.id); + accepted = await developmentRuns.get(input.runId); + } else { + accepted = await developmentRuns.create(targetSession, text, input.id); + } + if (input.stopRequested || (active() && stopIntent.current)) + accepted = await developmentRuns.stop(accepted.runId); + if (active()) { + setRun(accepted); + seedRun.current?.(accepted); + callbacks.current.onBusy(!runEnded(accepted)); + retryInput.current = null; + } + return true; + } catch (cause) { + if (active()) { + callbacks.current.onBusy(Boolean(run && !runEnded(run))); + setError(cause instanceof Error ? cause.message : String(cause)); + if (input.stopRequested) { + stopIntent.current = false; + setStopPending(false); + } + } + return false; + } finally { + if (active()) { + sending.current = false; + setSubmitting(false); + } + } + } + async function stop() { + stopIntent.current = true; + setStopPending(true); + setError(""); + if (retryInput.current) retryInput.current.stopRequested = true; + const epoch = generation.current; + try { + const runs = await developmentRuns.list(sessionId); + const active = runs.find((item) => !runEnded(item)); + if (active) { + const stopped = await developmentRuns.stop(active.runId); + if (epoch === generation.current) setRun(stopped); + } else if (!sending.current && epoch === generation.current) { + stopIntent.current = false; + setStopPending(false); + } + } catch (cause) { + if (epoch === generation.current) { + setError(cause instanceof Error ? cause.message : String(cause)); + setStopPending(false); + // The request was not confirmed. Allow a retry even when the initial + // submission also lost its response and no active run is known yet. + // retryInput.stopRequested still stops that submission if it is found. + stopIntent.current = false; + } + } + } + async function resume() { + if (!run) return; + const epoch = generation.current; + try { + const resumed = await developmentRuns.resume(run.runId); + if (epoch === generation.current) { + setRun(resumed); + setError(""); + } + } catch (cause) { + if (epoch === generation.current) + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + return { + run, + connection, + error, + submitting, + stopPending, + prepare, + submit, + stop, + resume, + }; +} diff --git a/frontend/src/i18n/resources/en-US/adk.json b/frontend/src/i18n/resources/en-US/adk.json index ad18b2830..2449c8cc6 100644 --- a/frontend/src/i18n/resources/en-US/adk.json +++ b/frontend/src/i18n/resources/en-US/adk.json @@ -553,5 +553,71 @@ "invalidProjectUrl": "The project URL is invalid", "listFallback": "Could not load projects", "connectionState": "Could not connect to the workspace. Try again" + }, + "developmentRuns": { + "reporting": "Completing delivery details", + "packaging": "Preparing artifacts", + "savingVersion": "Saving version", + "finishing": "Finishing request", + "submitResult": "Submit build result", + "requestFailed": "Task request failed. Please retry.", + "invalidResponse": "Invalid task state response.", + "eventGap": "Restoring missing task output.", + "reconnecting": "Reconnecting. Existing output is preserved.", + "input": { + "pending": "Queued", + "sending": "Confirming delivery", + "delivered": "Delivered", + "withdrawn": "Not sent" + }, + "plan": "Execution plan", + "diff": "File changes", + "preparing": "Preparing task", + "preparingEnvironment": "Preparing development environment…", + "connectingEnvironment": "Connecting to development environment…", + "processing": "Processing request", + "thinking": "Thinking", + "read": "Read file · {{target}}", + "listFiles": "List directory · {{target}}", + "search": "Search · {{target}}", + "command": "Run command · {{target}}", + "editFiles": "Edit files · {{target}}", + "webSearch": "Search web · {{target}}", + "processSummary": "Processed {{count}} items", + "duration": "{{seconds}}s", + "durationUnits": { + "milliseconds": "{{value}} ms", + "hours": "{{value}} h", + "minutes": "{{value}} min", + "seconds": "{{value}} s" + }, + "failedTools": "{{count}} tools failed", + "toolFailed": "Failed", + "toolCalls": "{{count}} tool calls", + "turnDuration": "Turn elapsed {{duration}}", + "toolDuration": "Tool time {{duration}}", + "toolDurationPartial": "Recorded tool time {{duration}}", + "toolDurationHelp": "Sum of tool durations. Parallel calls can exceed turn elapsed time.", + "turnStatus": { + "completed": "Completed", + "failed": "Failed", + "interrupted": "Interrupted", + "cancelled": "Interrupted", + "unavailable": "Task ended" + }, + "notReported": "Not reported", + "partial": "Recorded", + "partialHelp": "Usage for this turn may be incomplete.", + "tokenDetails": "Turn token usage", + "model": "Turn model", + "totalTokens": "Total", + "inputTokens": "Input", + "cachedInputTokens": "Cached input", + "uncachedInputTokens": "Uncached input", + "cacheWriteInputTokens": "Cache write", + "outputTokens": "Output", + "reasoningOutputTokens": "Reasoning output", + "cacheHitRate": "Input cache hit rate", + "tokenHelp": "Cached input and reasoning output are subsets of input and output. Uncached input = input − cached input." } } diff --git a/frontend/src/i18n/resources/en-US/conversation.json b/frontend/src/i18n/resources/en-US/conversation.json index c2d848c92..01068fec0 100644 --- a/frontend/src/i18n/resources/en-US/conversation.json +++ b/frontend/src/i18n/resources/en-US/conversation.json @@ -126,6 +126,7 @@ "downloadSource": "Download source", "sourceNotReady": "The source is not ready yet", "manualDeploy": "Deploy manually to Runtime", + "deployAgent": "Deploy Agent", "beforeOptimization": "Before optimization", "afterOptimization": "After optimization", "planStatuses": { diff --git a/frontend/src/i18n/resources/en-US/create.json b/frontend/src/i18n/resources/en-US/create.json index 940e5f46f..92ab9d534 100644 --- a/frontend/src/i18n/resources/en-US/create.json +++ b/frontend/src/i18n/resources/en-US/create.json @@ -206,8 +206,8 @@ } }, "intelligent": { - "title": "Intelligent mode", - "subtitle": "Describe your goal and Codex will plan, build, debug, and temporarily validate the agent in a sandbox.", + "title": "Intelligent build", + "subtitle": "Describe what you need. Build, debug, and validate your agent.", "model": { "label": "Model", "placeholder": "Select a model", @@ -241,6 +241,29 @@ "preparing": "Creating the task environment…", "starting": "Environment ready. Starting Codex…", "next": "Next, Codex will plan the approach, then build, run, and validate the agent." + }, + "tasks": { + "title": "Tasks in progress", + "hint": "Work continues when you leave. Return to follow progress or add instructions.", + "refresh": "Refresh tasks", + "loading": "Loading tasks…", + "empty": "No tasks in progress", + "emptyHint": "Once a build starts, you can return to it here.", + "loadError": "Unable to load tasks. Please retry.", + "openError": "Unable to open this task. Please retry.", + "startedAt": "Started {{time}}", + "open": "Open task", + "opening": "Connecting…", + "states": { + "queued": "Queued", + "running": "Building", + "recovering": "Reconnecting", + "waiting_user": "Awaiting your reply", + "stopping": "Stopping", + "succeeded": "Completed", + "failed": "Incomplete", + "cancelled": "Stopped" + } } }, "projectLibrary": { diff --git a/frontend/src/i18n/resources/en-US/sandbox.json b/frontend/src/i18n/resources/en-US/sandbox.json index 6998a5f0c..e0bcef54e 100644 --- a/frontend/src/i18n/resources/en-US/sandbox.json +++ b/frontend/src/i18n/resources/en-US/sandbox.json @@ -97,7 +97,11 @@ "continuePlaceholder": "Describe what you want to implement or adjust next", "messagePlaceholder": "Message the AgentKit Sandbox. Type / for commands or $ to invoke a Skill…", "stop": "Stop generating", - "send": "Send" + "send": "Send", + "stopping": "Confirming stop…", + "resume": "Resume task", + "steer": "Add instructions", + "steerPlaceholder": "Add instructions, or stop the task at any time…" }, "launch": { "defaultName": "My Agent", @@ -265,5 +269,19 @@ "status": "Current Codex status", "help": "Codex shortcuts supported by Sandbox" } + }, + "taskNotice": { + "label": "Build tasks", + "queued": "Build queued", + "running": "Build running in the background", + "recovering": "Recovering build", + "waiting_user": "Build needs attention", + "stopping": "Stopping build", + "succeeded": "Build completed", + "cancelled": "Build stopped", + "failed": "Build could not complete", + "open": "Return to task", + "hide": "Hide task notification", + "reconnecting": "Reconnecting to the task service. Existing tasks are retained." } } diff --git a/frontend/src/i18n/resources/zh-CN/adk.json b/frontend/src/i18n/resources/zh-CN/adk.json index 7f1298d56..76d83a3eb 100644 --- a/frontend/src/i18n/resources/zh-CN/adk.json +++ b/frontend/src/i18n/resources/zh-CN/adk.json @@ -553,5 +553,71 @@ "invalidProjectUrl": "项目访问地址无效", "listFallback": "读取项目列表失败", "connectionState": "暂时无法连接工作区,请重试" + }, + "developmentRuns": { + "reporting": "正在补齐交付信息", + "packaging": "正在整理产物", + "savingVersion": "正在保存版本", + "finishing": "正在完成请求", + "submitResult": "提交构建结果", + "requestFailed": "任务请求失败,请重试。", + "invalidResponse": "任务状态响应无效。", + "eventGap": "正在补齐任务输出。", + "reconnecting": "连接暂时中断,正在重连。已有输出已保留。", + "input": { + "pending": "等待送达", + "sending": "正在确认送达", + "delivered": "已送达", + "withdrawn": "已停止发送" + }, + "plan": "执行计划", + "diff": "文件变更", + "preparing": "正在准备任务", + "preparingEnvironment": "正在准备开发环境…", + "connectingEnvironment": "正在连接开发环境…", + "processing": "正在处理请求", + "thinking": "正在思考", + "read": "读取文件 · {{target}}", + "listFiles": "查看目录 · {{target}}", + "search": "搜索 · {{target}}", + "command": "执行命令 · {{target}}", + "editFiles": "修改文件 · {{target}}", + "webSearch": "搜索网页 · {{target}}", + "processSummary": "已处理 {{count}} 项", + "duration": "{{seconds}} 秒", + "durationUnits": { + "milliseconds": "{{value}} 毫秒", + "hours": "{{value}} 小时", + "minutes": "{{value}} 分", + "seconds": "{{value}} 秒" + }, + "failedTools": "{{count}} 项执行失败", + "toolFailed": "执行失败", + "toolCalls": "{{count}} 次工具调用", + "turnDuration": "本轮耗时 {{duration}}", + "toolDuration": "工具累计耗时 {{duration}}", + "toolDurationPartial": "已记录工具耗时 {{duration}}", + "toolDurationHelp": "各工具执行耗时之和;并行调用可能使累计耗时超过本轮耗时。", + "turnStatus": { + "completed": "已完成", + "failed": "未完成", + "interrupted": "已中断", + "cancelled": "已中断", + "unavailable": "任务已结束" + }, + "notReported": "未上报", + "partial": "已记录", + "partialHelp": "本轮记录可能不完整。", + "tokenDetails": "本轮 Token 用量", + "model": "本轮模型", + "totalTokens": "总量", + "inputTokens": "输入", + "cachedInputTokens": "缓存命中输入", + "uncachedInputTokens": "未命中输入", + "cacheWriteInputTokens": "缓存写入", + "outputTokens": "输出", + "reasoningOutputTokens": "推理输出", + "cacheHitRate": "输入缓存命中率", + "tokenHelp": "缓存命中属于输入,推理输出属于输出,不重复计入总量。未命中输入 = 输入 − 缓存命中。" } } diff --git a/frontend/src/i18n/resources/zh-CN/conversation.json b/frontend/src/i18n/resources/zh-CN/conversation.json index 52597ec01..78bcf9ef2 100644 --- a/frontend/src/i18n/resources/zh-CN/conversation.json +++ b/frontend/src/i18n/resources/zh-CN/conversation.json @@ -126,6 +126,7 @@ "downloadSource": "下载源码", "sourceNotReady": "源码尚未准备好", "manualDeploy": "手动部署到 Runtime", + "deployAgent": "部署 Agent", "beforeOptimization": "优化前", "afterOptimization": "优化后", "planStatuses": { diff --git a/frontend/src/i18n/resources/zh-CN/create.json b/frontend/src/i18n/resources/zh-CN/create.json index 45d5cf03c..cfa672fa6 100644 --- a/frontend/src/i18n/resources/zh-CN/create.json +++ b/frontend/src/i18n/resources/zh-CN/create.json @@ -206,8 +206,8 @@ } }, "intelligent": { - "title": "智能模式", - "subtitle": "描述目标后,沙箱中的 Codex 会判断你的意图,完成构建、调试和临时云端验证。", + "title": "智能构建", + "subtitle": "描述需求,完成 Agent 的构建、调试与验证。", "model": { "label": "模型", "placeholder": "选择模型", @@ -241,6 +241,29 @@ "preparing": "正在创建任务环境…", "starting": "环境已就绪,正在启动 Codex…", "next": "接下来会先梳理目标和实现方式,再编写、运行和验证 Agent。" + }, + "tasks": { + "title": "进行中的任务", + "hint": "离开页面后仍会继续,可随时回来查看和补充要求。", + "refresh": "刷新任务列表", + "loading": "正在读取任务…", + "empty": "暂无进行中的任务", + "emptyHint": "开始构建后,可以从这里回到任务。", + "loadError": "暂时无法读取任务,请重试。", + "openError": "暂时无法打开任务,请重试。", + "startedAt": "开始于 {{time}}", + "open": "查看任务", + "opening": "正在连接…", + "states": { + "queued": "等待开始", + "running": "构建中", + "recovering": "正在重连", + "waiting_user": "等待你的回复", + "stopping": "正在停止", + "succeeded": "已完成", + "failed": "未完成", + "cancelled": "已停止" + } } }, "projectLibrary": { diff --git a/frontend/src/i18n/resources/zh-CN/sandbox.json b/frontend/src/i18n/resources/zh-CN/sandbox.json index e7d356f83..00c46bb45 100644 --- a/frontend/src/i18n/resources/zh-CN/sandbox.json +++ b/frontend/src/i18n/resources/zh-CN/sandbox.json @@ -97,7 +97,11 @@ "continuePlaceholder": "继续说明你想实现或调整的内容", "messagePlaceholder": "向 AgentKit 沙箱发送消息,输入 / 查看命令,输入 $ 调用 Skill…", "stop": "停止生成", - "send": "发送" + "send": "发送", + "stopping": "正在确认停止…", + "resume": "恢复任务", + "steer": "追加要求", + "steerPlaceholder": "可继续追加要求,或随时停止任务…" }, "launch": { "defaultName": "我的智能体", @@ -265,5 +269,19 @@ "status": "Codex 当前状态", "help": "Sandbox 支持的 Codex 快捷命令" } + }, + "taskNotice": { + "label": "智能构建任务", + "queued": "构建任务已排队", + "running": "构建任务正在后台运行", + "recovering": "正在恢复构建任务", + "waiting_user": "构建任务需要继续处理", + "stopping": "正在停止构建任务", + "succeeded": "构建任务已完成", + "cancelled": "构建任务已停止", + "failed": "构建任务未完成", + "open": "返回任务", + "hide": "隐藏任务通知", + "reconnecting": "正在重新连接任务服务,已有任务继续保留。" } } diff --git a/frontend/src/migrations/IntelligentOptimizationDialog.css b/frontend/src/migrations/IntelligentOptimizationDialog.css index e96e21785..c68b12d54 100644 --- a/frontend/src/migrations/IntelligentOptimizationDialog.css +++ b/frontend/src/migrations/IntelligentOptimizationDialog.css @@ -9,6 +9,8 @@ backdrop-filter: blur(3px); } +.migration-optimize-backdrop[hidden] { display: none; } + .migration-optimize-dialog { width: min(720px, 94vw); max-height: min(820px, 90vh); diff --git a/frontend/src/migrations/IntelligentOptimizationDialog.tsx b/frontend/src/migrations/IntelligentOptimizationDialog.tsx index bbf5aa9db..a1054d38c 100644 --- a/frontend/src/migrations/IntelligentOptimizationDialog.tsx +++ b/frontend/src/migrations/IntelligentOptimizationDialog.tsx @@ -45,6 +45,7 @@ export function IntelligentOptimizationDialog({ onCloseRef.current = onClose; useEffect(() => { + if (busy) return; const previousOverflow = document.body.style.overflow; const previousFocus = document.activeElement instanceof HTMLElement @@ -84,11 +85,12 @@ export function IntelligentOptimizationDialog({ window.removeEventListener("keydown", handleKeyDown); if (previousFocus?.isConnected) previousFocus.focus(); }; - }, []); + }, [busy]); return createPortal(