From 033b271179e4f39f4e2eeace3031dbdb513a7e7b Mon Sep 17 00:00:00 2001 From: superops Date: Fri, 9 Oct 2026 19:18:03 +0800 Subject: [PATCH] feat(studio): integrate managed MPA runtime lifecycle Refresh the managed MPA and Studio runtime lifecycle integration on top of the latest upstream main, including the fork named-creation update, rebuilt web assets, the official TruffleHog scan configuration, and the task-cancellation regression hardening. --- .agents/plugins/marketplace.json | 20 - .gitattributes | 1 + .github/workflows/secrets-scan.yaml | 2 +- .gitignore | 19 + .gitleaks.toml | 5 + .pre-commit-config.yaml | 9 +- .trufflehog-exclude-paths.txt | 1 + AGENTS.md | 68 +- contracts/mpa-p0/benchmark-report.schema.json | 23 + contracts/mpa-p0/compatibility-matrix.json | 20 + examples/piagent_with_mcp/README.md | 12 +- .../piagent-mcp-agentkit.yaml | 14 +- frontend/README.md | 208 +- frontend/package-lock.json | 7 + frontend/package.json | 6 +- frontend/scripts/verifyBuiltAssets.mjs | 5 + frontend/server/mpa/__init__.py | 74 + frontend/server/mpa/operations.py | 452 ++++ frontend/server/mpa/routes.py | 1053 ++++++++ frontend/server/mpa/runtime_client.py | 368 +++ frontend/server/mpa/service.py | 323 +++ frontend/server/mpa/test_scenarios.py | 1654 +++++++++++++ frontend/server/mpa_a2a.py | 51 + frontend/server/mpa_agent_info.py | 134 ++ frontend/server/mpa_creation.py | 222 ++ frontend/server/mpa_cron.py | 196 ++ frontend/server/mpa_identity_callback.py | 438 ++++ frontend/server/runtime_logs.py | 5 +- frontend/server/skills/documents.py | 59 + frontend/server/skills/version_routes.py | 40 + frontend/server/skills/versions.py | 72 + frontend/src/App.tsx | 1017 ++++++-- frontend/src/adk/channels.test.ts | 15 + frontend/src/adk/client.ts | 1386 ++++++++++- frontend/src/adk/connections.ts | 32 +- frontend/src/adk/deploymentI18n.ts | 3 + frontend/src/adk/mpa-identity-prewarm.test.ts | 107 + frontend/src/adk/mpaCreation.ts | 106 + frontend/src/adk/mpaCreationImages.ts | 10 + frontend/src/adk/mpaCreationResources.ts | 38 + frontend/src/adk/mpaCronTasks.ts | 210 ++ frontend/src/adk/skillDocuments.ts | 69 + frontend/src/adk/sse.ts | 25 +- frontend/src/adk/wecom-sdk.d.ts | 3 + frontend/src/adk/wecomAuthorization.ts | 77 + .../mpa-channels/MpaChannelsAutomation.css | 153 ++ .../mpa-channels/MpaChannelsAutomation.tsx | 268 +++ .../mpa-channels/MpaChannelsIcon.tsx | 22 + frontend/src/automations/mpaChannels.ts | 10 + frontend/src/automations/registry.ts | 2 + frontend/src/automations/types.ts | 25 +- frontend/src/blocks.ts | 644 ++++- .../ConversationFlow/StudioConversation.tsx | 5 + .../layouts/ModalLayout/ModalLayout.tsx | 10 +- .../components/primitives/Button/Button.css | 5 + frontend/src/create/CustomCreate.tsx | 267 ++- frontend/src/create/NewAgentWorkbench.tsx | 26 +- frontend/src/create/agentDraftStorage.ts | 2 + frontend/src/create/skills/skillspace.ts | 6 +- frontend/src/cronjobs/CronJobs.tsx | 12 +- frontend/src/cronjobs/MpaCronTasks.css | 427 ++++ frontend/src/cronjobs/MpaCronTasks.tsx | 621 +++++ frontend/src/cronjobs/MpaTaskDetail.tsx | 147 ++ frontend/src/cronjobs/MpaTaskEditor.tsx | 346 +++ frontend/src/cronjobs/MpaTaskIcons.tsx | 31 + frontend/src/cronjobs/mpaSchedule.ts | 185 ++ frontend/src/i18n/resources/en-US/adk.json | 14 +- .../src/i18n/resources/en-US/automations.json | 22 + .../i18n/resources/en-US/conversation.json | 99 +- frontend/src/i18n/resources/en-US/create.json | 12 +- .../src/i18n/resources/en-US/cronjobs.json | 107 + .../src/i18n/resources/en-US/newChat.json | 1 + frontend/src/i18n/resources/en-US/ui.json | 338 ++- .../i18n/resources/en-US/workspaceTools.json | 40 +- frontend/src/i18n/resources/zh-CN/adk.json | 14 +- .../src/i18n/resources/zh-CN/automations.json | 22 + .../i18n/resources/zh-CN/conversation.json | 99 +- frontend/src/i18n/resources/zh-CN/create.json | 12 +- .../src/i18n/resources/zh-CN/cronjobs.json | 107 + .../src/i18n/resources/zh-CN/newChat.json | 1 + frontend/src/i18n/resources/zh-CN/ui.json | 338 ++- .../i18n/resources/zh-CN/workspaceTools.json | 40 +- frontend/src/main.tsx | 6 + frontend/src/styles.css | 294 ++- frontend/src/transcriptReconcile.ts | 29 + frontend/src/transcriptRows.ts | 69 + frontend/src/ui/AgentSelector.tsx | 7 +- frontend/src/ui/AgentTopology.tsx | 421 +--- frontend/src/ui/AgentWorkspace.css | 280 ++- frontend/src/ui/AgentWorkspace.tsx | 1549 +++++++++++- frontend/src/ui/Applications.tsx | 16 +- frontend/src/ui/Blocks.tsx | 239 +- frontend/src/ui/Composer.tsx | 104 +- frontend/src/ui/DeploymentSelect.tsx | 6 +- frontend/src/ui/EnvironmentCenter.tsx | 16 +- frontend/src/ui/GithubCicdPanel.tsx | 6 + frontend/src/ui/ManageAgents.tsx | 18 +- frontend/src/ui/Markdown.tsx | 9 +- frontend/src/ui/MyAgents.tsx | 97 +- frontend/src/ui/ProjectPreview.css | 11 + frontend/src/ui/ProjectPreview.tsx | 309 ++- frontend/src/ui/ResourceCollection.css | 5 +- frontend/src/ui/RuntimeChannels.css | 304 +++ frontend/src/ui/RuntimeChannels.tsx | 933 ++++++++ frontend/src/ui/RuntimeLogsDialog.css | 34 + frontend/src/ui/RuntimeLogsDialog.tsx | 42 + frontend/src/ui/SandboxFileLink.tsx | 65 + frontend/src/ui/Sidebar.tsx | 2 +- frontend/src/ui/StudioConfirmDialog.tsx | 6 +- frontend/src/ui/TokenUsageIndicator.tsx | 21 + frontend/src/ui/WorkspaceCenter.tsx | 19 +- .../ui/builtin-tools/codexSandboxProgress.ts | 13 +- .../ui/mpa-agent-info/MpaAgentInfoRail.tsx | 151 ++ .../ui/mpa-agent-info/SkillDocumentEditor.css | 81 + .../ui/mpa-agent-info/SkillDocumentEditor.tsx | 224 ++ .../src/ui/mpa-create/MpaCreateDialog.css | 171 ++ .../src/ui/mpa-create/MpaCreateDialog.tsx | 741 ++++++ .../ui/new-chat-modes/NewChatAgentPicker.tsx | 75 +- .../new-chat-modes/NewChatCompactSelect.tsx | 102 +- .../ui/new-chat-modes/new-chat-workspace.css | 13 +- .../src/ui/tool-activity/ToolActivityCard.tsx | 380 +++ .../ui/tool-activity/ToolActivityIcons.tsx | 121 + frontend/src/ui/tool-activity/model.ts | 470 ++++ .../src/ui/tool-activity/tool-activity.css | 276 +++ frontend/tests/a2aWaiting.test.tsx | 60 + frontend/tests/adkI18n.test.mjs | 21 + frontend/tests/agentInfoRail.test.mjs | 133 +- frontend/tests/agentWorkspace.test.mjs | 223 +- frontend/tests/codexSandboxProgress.test.mjs | 45 +- frontend/tests/composerPresentation.test.mjs | 5 +- frontend/tests/deploymentConfigUi.test.mjs | 2 +- frontend/tests/githubCicdPanel.test.mjs | 13 + .../tests/intelligentDevelopment.test.mjs | 4 +- frontend/tests/issueFeedback.test.mjs | 2 +- .../tests/manageAgentsConnection.test.mjs | 14 +- frontend/tests/messageFeedback.test.mjs | 2 +- frontend/tests/mpaA2aTranscript.test.mjs | 106 + frontend/tests/mpaAgentInfoRail.test.tsx | 230 ++ frontend/tests/mpaChannelsAutomation.test.tsx | 439 ++++ .../tests/mpaContentPreservation.test.mjs | 154 ++ frontend/tests/mpaCreation.test.tsx | 924 +++++++ frontend/tests/mpaCronTasks.test.tsx | 99 + frontend/tests/mpaManagement.test.tsx | 959 ++++++++ frontend/tests/mpaResponseGrouping.test.mjs | 76 + frontend/tests/mpaSessionConfigUi.test.mjs | 104 + .../mpaSessionExecutionConfigClient.test.mjs | 42 + frontend/tests/mpaSessionProtocol.test.mjs | 221 ++ frontend/tests/myAgents.test.mjs | 36 +- frontend/tests/newAgentWorkbench.test.mjs | 80 +- frontend/tests/newChatAgentPicker.test.mjs | 48 +- frontend/tests/newChatComposerLayout.test.mjs | 118 +- .../tests/newChatModeCapabilities.test.mjs | 2 +- .../tests/parallelStreamAggregation.test.mjs | 265 ++ frontend/tests/parseSSE.test.mjs | 12 +- frontend/tests/privateRuntimeDeploy.test.mjs | 2 +- frontend/tests/responseAnnotation.test.mjs | 6 +- frontend/tests/runSseAbort.test.mjs | 788 +++++- frontend/tests/runSseCompletion.test.mjs | 113 +- frontend/tests/runtimeAgentIdentity.test.mjs | 5 +- frontend/tests/runtimeChannels.test.tsx | 979 ++++++++ ...runtimeControlAndEnvironmentState.test.mjs | 30 + frontend/tests/runtimeLogs.test.mjs | 12 + frontend/tests/runtimeScalingDeploy.test.mjs | 54 +- frontend/tests/sandboxDownload.test.tsx | 74 + frontend/tests/sandboxDownloadClient.test.ts | 25 + .../tests/sessionEnvironmentMount.test.mjs | 10 +- frontend/tests/skillCenter.test.mjs | 5 +- frontend/tests/skillDocumentEditor.test.tsx | 161 ++ frontend/tests/stopGeneration.test.mjs | 17 +- frontend/tests/studioAccess.test.mjs | 8 +- frontend/tests/studioBffTools.test.mjs | 36 +- frontend/tests/studioConversation.test.tsx | 11 +- frontend/tests/tokenUsageIndicator.test.mjs | 6 + frontend/tests/toolActivityModel.test.mjs | 844 +++++++ frontend/tests/toolBlockDefaultOpen.test.mjs | 152 +- frontend/tests/viteProxy.test.mjs | 7 + frontend/vite.config.ts | 1 + frontend/vitest.a2a-wait.config.ts | 8 + frontend/vitest.mpa-cron.config.ts | 17 + frontend/vitest.sandbox-download.config.ts | 13 + .../proposal.md | 19 + .../mpa-agent-studio-a2a-streaming/tasks.md | 23 + .../mpa-p0-productionization/proposal.md | 70 + .../changes/mpa-p0-productionization/tasks.md | 342 +++ .../proposal.md | 20 + .../tasks.md | 16 + prd-spec/README.md | 130 + prd-spec/README.zh.md | 130 + .../2026-09-12-a2a-first-event-heartbeat.md | 51 + ...2026-09-12-a2a-first-event-heartbeat.zh.md | 51 + .../2026-09-23-aidap-workspace-readiness.md | 33 + ...2026-09-23-aidap-workspace-readiness.zh.md | 33 + .../2026-09-23-mpa-a2a-discovery-default.md | 48 + ...2026-09-23-mpa-a2a-discovery-default.zh.md | 48 + .../2026-09-23-mpa-registry-tls-url.md | 35 + .../2026-09-23-mpa-registry-tls-url.zh.md | 35 + .../a2a-long-running/2026-09-15-wait.md | 17 + .../a2a-long-running/2026-09-15-wait.zh.md | 17 + .../2026-09-15-channel-group-actions.md | 17 + .../2026-09-15-channel-group-actions.zh.md | 17 + .../2026-09-15-channel-qr-lifetime.md | 21 + .../2026-09-15-channel-qr-lifetime.zh.md | 21 + .../2026-09-14-chat-model-dropdown.md | 56 + .../2026-09-14-chat-model-dropdown.zh.md | 56 + .../2026-09-17-replay.md | 13 + .../2026-09-17-replay.zh.md | 13 + .../2026-09-14-mpa-agent-filtering.md | 61 + .../2026-09-14-mpa-agent-filtering.zh.md | 61 + .../2026-09-15-mpa-channel-visibility.md | 28 + .../2026-09-15-mpa-channel-visibility.zh.md | 28 + .../2026-09-30-explicit-new-request.md | 56 + .../2026-09-30-explicit-new-request.zh.md | 47 + .../2026-09-30-recover-step-navigation.md | 45 + .../2026-09-30-recover-step-navigation.zh.md | 43 + .../mpa-default-jwt/2026-09-15-default-jwt.md | 9 + .../2026-09-15-default-jwt.zh.md | 9 + .../2026-09-30-studio-key-auth-default.md | 98 + .../2026-09-30-studio-key-auth-default.zh.md | 87 + ...-09-29-ensure-managed-workload-identity.md | 80 + ...-29-ensure-managed-workload-identity.zh.md | 73 + .../2026-09-29-set-fresh-runtime-command.md | 50 + ...2026-09-29-set-fresh-runtime-command.zh.md | 45 + ...6-09-29-runtime-initial-tool-attachment.md | 90 + ...9-29-runtime-initial-tool-attachment.zh.md | 75 + ...09-12-mpa-runtime-integration-hardening.md | 160 ++ ...12-mpa-runtime-integration-hardening.zh.md | 160 ++ .../2026-09-13-dev-loop-execution.md | 40 + .../2026-09-13-dev-loop-execution.zh.md | 40 + .../2026-09-13-dev-loop-review.md | 31 + .../2026-09-13-dev-loop-review.zh.md | 31 + .../2026-09-13-functional-validation-cases.md | 23 + ...26-09-13-functional-validation-cases.zh.md | 23 + .../2026-10-07-persistent-mcp-secret.md | 78 + .../2026-10-07-persistent-mcp-secret.zh.md | 78 + .../2026-09-29-preserve-runtime-command.md | 60 + .../2026-09-29-preserve-runtime-command.zh.md | 51 + .../2026-09-29-typed-event-dedup.md | 46 + .../2026-09-29-typed-event-dedup.zh.md | 46 + .../2026-09-28-registry-owned-network.md | 115 + .../2026-09-28-registry-owned-network.zh.md | 98 + .../2026-09-21-mpa-test-types.md | 50 + .../2026-09-21-mpa-test-types.zh.md | 50 + .../2026-09-28-agent-derived-worker-name.md | 52 + ...2026-09-28-agent-derived-worker-name.zh.md | 52 + .../2026-09-20-worker-metadata-visibility.md | 53 + ...026-09-20-worker-metadata-visibility.zh.md | 53 + .../2026-09-20-worker-retry.md | 56 + .../2026-09-20-worker-retry.zh.md | 56 + .../2026-09-30-worker-metadata-deadline.md | 54 + .../2026-09-30-worker-metadata-deadline.zh.md | 54 + .../2026-09-17-default-db-spans.md | 9 + .../2026-09-17-default-db-spans.zh.md | 9 + .../2026-09-22-mpa-a2a-final.md | 56 + .../2026-09-22-mpa-a2a-final.zh.md | 56 + .../2026-09-22-mpa-a2a-reasoning.md | 34 + .../2026-09-22-mpa-a2a-reasoning.zh.md | 34 + .../2026-09-22-mpa-direct-answer-reasoning.md | 50 + ...26-09-22-mpa-direct-answer-reasoning.zh.md | 50 + .../2026-09-22-mpa-reasoning-snapshots.md | 36 + .../2026-09-22-mpa-reasoning-snapshots.zh.md | 36 + .../2026-09-22-mpa-wrapper-preview.md | 46 + .../2026-09-22-mpa-wrapper-preview.zh.md | 46 + .../2026-09-22-preserve-followup-content.md | 35 + ...2026-09-22-preserve-followup-content.zh.md | 35 + .../2026-09-22-preserve-sandbox-reasoning.md | 36 + ...026-09-22-preserve-sandbox-reasoning.zh.md | 36 + ...026-09-22-studio-codex-commentary-dedup.md | 113 + ...-09-22-studio-codex-commentary-dedup.zh.md | 113 + .../studio-cron-e2e/2026-09-15-integration.md | 17 + .../2026-09-15-integration.zh.md | 17 + .../2026-09-30-prefer-a2a.md | 97 + .../2026-09-30-prefer-a2a.zh.md | 83 + ...026-09-23-complete-aidap-runtime-policy.md | 86 + ...-09-23-complete-aidap-runtime-policy.zh.md | 86 + ...09-23-complete-mpa-cloud-runtime-policy.md | 94 + ...23-complete-mpa-cloud-runtime-policy.zh.md | 94 + .../2026-09-30-hide-artifact-entry.md | 75 + .../2026-09-30-hide-artifact-entry.zh.md | 62 + ...09-24-start-another-agent-after-failure.md | 55 + ...24-start-another-agent-after-failure.zh.md | 55 + .../2026-09-20-dialog-theme.md | 50 + .../2026-09-20-dialog-theme.zh.md | 50 + .../2026-09-20-studio-mpa-profile-errors.md | 97 + ...2026-09-20-studio-mpa-profile-errors.zh.md | 97 + .../2026-09-23-use-temporary-state.md | 82 + .../2026-09-23-use-temporary-state.zh.md | 82 + .../2026-10-07-align-sdk-wheel.md | 99 + .../2026-10-07-align-sdk-wheel.zh.md | 87 + .../2026-09-22-a2a-session-routing.md | 51 + .../2026-09-22-a2a-session-routing.zh.md | 51 + .../2026-09-22-studio-startup-assets.md | 194 ++ .../2026-09-22-studio-startup-assets.zh.md | 194 ++ .../2026-09-17-session-trace.md | 13 + .../2026-09-17-session-trace.zh.md | 13 + .../2026-09-23-rebuild-corrupted-assets.md | 37 + .../2026-09-23-rebuild-corrupted-assets.zh.md | 37 + .../2026-09-23-yaml-secret-guard.md | 72 + .../2026-09-23-yaml-secret-guard.zh.md | 72 + .../2026-09-15-channel-auth-methods.md | 34 + .../2026-09-15-channel-auth-methods.zh.md | 34 + .../2026-09-15-channel-configuration-modes.md | 73 + ...26-09-15-channel-configuration-modes.zh.md | 73 + .../2026-09-15-channel-qr-regeneration.md | 36 + .../2026-09-15-channel-qr-regeneration.zh.md | 36 + .../2026-09-15-message-channel-binding.md | 61 + .../2026-09-15-message-channel-binding.zh.md | 61 + .../2026-09-15-message-channel-workspace.md | 50 + ...2026-09-15-message-channel-workspace.zh.md | 50 + .../2026-09-18-mpa-agent-info-rail.md | 94 + .../2026-09-18-mpa-agent-info-rail.zh.md | 94 + ...-10-mpa-agent-oneclick-provision-design.md | 221 ++ ...-mpa-agent-oneclick-provision-design.zh.md | 221 ++ ...t-oneclick-provision-verification-cases.md | 182 ++ ...neclick-provision-verification-cases.zh.md | 159 ++ ...rovision-orchestration-alignment-design.md | 228 ++ ...ision-orchestration-alignment-design.zh.md | 226 ++ ...2-mpa-agent-studio-a2a-streaming-design.md | 230 ++ ...pa-agent-studio-a2a-streaming-design.zh.md | 230 ++ ...studio-a2a-streaming-verification-cases.md | 48 + ...dio-a2a-streaming-verification-cases.zh.md | 48 + .../2026-09-20-studio-mpa-creation.md | 216 ++ .../2026-09-20-studio-mpa-creation.zh.md | 216 ++ .../2026-09-23-mpa-tos-output-mount.md | 77 + .../2026-09-23-mpa-tos-output-mount.zh.md | 66 + .../2026-09-23-studio-builtin-mpa-profile.md | 52 + ...026-09-23-studio-builtin-mpa-profile.zh.md | 52 + .../2026-09-23-studio-mpa-openviking-key.md | 42 + ...2026-09-23-studio-mpa-openviking-key.zh.md | 42 + ...26-09-23-studio-mpa-three-step-creation.md | 55 + ...09-23-studio-mpa-three-step-creation.zh.md | 55 + .../2026-10-09-named-studio-creation.md | 83 + .../2026-10-09-named-studio-creation.zh.md | 83 + .../evidence/2026-09-11-code-review.zh.md | 57 + .../2026-09-11-verification-execution.zh.md | 73 + .../mpa-create.config.example.yaml | 146 ++ .../2026-09-16-mpa-channels-automation.md | 103 + .../2026-09-16-mpa-channels-automation.zh.md | 103 + .../2026-09-15-mpa-feishu-channels.md | 31 + .../2026-09-15-mpa-feishu-channels.zh.md | 31 + .../2026-09-15-dev-loop-spec-review.md | 56 + .../2026-09-15-dev-loop-spec-review.zh.md | 56 + .../2026-09-15-functional-validation-cases.md | 286 +++ ...26-09-15-functional-validation-cases.zh.md | 286 +++ .../2026-09-15-m0-evidence.md | 26 + .../2026-09-15-m0-evidence.zh.md | 26 + ...6-09-15-mpa-p0-productionization-design.md | 438 ++++ ...9-15-mpa-p0-productionization-design.zh.md | 438 ++++ .../mpa-p0-productionization/browser-cases.md | 101 + .../browser-cases.zh.md | 101 + .../2026-09-22-mpa-response-grouping.md | 38 + .../2026-09-22-mpa-response-grouping.zh.md | 38 + .../2026-09-21-deployment-account-pg.md | 89 + .../2026-09-21-deployment-account-pg.zh.md | 89 + .../2026-09-23-auto-pg-workspaces.md | 64 + .../2026-09-23-auto-pg-workspaces.zh.md | 64 + .../2026-09-23-space-scoped-manual-pg.md | 49 + .../2026-09-23-space-scoped-manual-pg.zh.md | 49 + .../2026-09-23-two-pg-workspaces.md | 57 + .../2026-09-23-two-pg-workspaces.zh.md | 57 + ...6-09-13-mpa-studio-control-plane-design.md | 170 ++ ...9-13-mpa-studio-control-plane-design.zh.md | 170 ++ ...tudio-control-plane-implementation-plan.md | 139 ++ ...io-control-plane-implementation-plan.zh.md | 130 + ...2026-09-20-mpa-studio-workload-identity.md | 124 + ...6-09-20-mpa-studio-workload-identity.zh.md | 124 + .../2026-09-15-mono-task-management.md | 54 + .../2026-09-15-mono-task-management.zh.md | 54 + .../2026-09-15-remove-agent-input.md | 5 + .../2026-09-15-remove-agent-input.zh.md | 5 + .../features/studio-mpa-cron-tasks/README.md | 46 + .../studio-mpa-cron-tasks/README.zh.md | 46 + .../2026-09-23-deploy-identity-reuse.md | 35 + .../2026-09-23-deploy-identity-reuse.zh.md | 35 + .../2026-09-23-studio-mpa-prewarm.md | 31 + .../2026-09-23-studio-mpa-prewarm.zh.md | 31 + .../2026-09-20-main-integration.md | 9 + .../2026-09-20-main-integration.zh.md | 9 + .../2026-09-28-studio-mpa-source-filter.md | 128 + .../2026-09-28-studio-mpa-source-filter.zh.md | 128 + .../2026-09-23-retain-id-token.md | 39 + .../2026-09-23-retain-id-token.zh.md | 39 + .../2026-09-17-history-download.md | 36 + .../2026-09-17-history-download.zh.md | 36 + ...udio-tool-activity-visualization-design.md | 276 +++ ...o-tool-activity-visualization-design.zh.md | 276 +++ pyproject.toml | 2 +- scripts/benchmark-mpa-p0.py | 121 + scripts/mpa_p0_contract.py | 62 + scripts/mpa_p0_e2e.py | 364 +++ scripts/mpa_p0_vc21_driver.py | 1597 +++++++++++++ scripts/run-mpa-p0-browser-fixture.sh | 26 + scripts/scan_yaml_secrets.py | 164 ++ scripts/verify-mpa-p0-contract.sh | 5 + scripts/verify-mpa-p0-e2e.sh | 13 + specs/README.md | 114 + specs/README.zh.md | 114 + specs/mpa-channels/README.md | 41 + specs/mpa-channels/README.zh.md | 41 + specs/mpa-runtime-control/README.md | 193 ++ specs/mpa-runtime-control/README.zh.md | 193 ++ specs/mpa-runtime-provisioning/README.md | 122 + specs/mpa-runtime-provisioning/README.zh.md | 112 + specs/studio-mpa-control-plane/README.md | 131 + specs/studio-mpa-control-plane/README.zh.md | 131 + specs/studio-mpa-creation/README.md | 138 ++ specs/studio-mpa-creation/README.zh.md | 136 ++ specs/studio-mpa-cron-tasks/README.md | 36 + specs/studio-mpa-cron-tasks/README.zh.md | 36 + specs/studio-mpa-prewarm/README.md | 18 + specs/studio-mpa-prewarm/README.zh.md | 18 + specs/studio-oauth-session/README.md | 26 + specs/studio-oauth-session/README.zh.md | 26 + specs/studio-runtime-diagnostics/README.md | 63 + specs/studio-runtime-diagnostics/README.zh.md | 63 + specs/studio-sandbox-download/README.md | 21 + specs/studio-sandbox-download/README.zh.md | 21 + specs/studio-skill-document/README.md | 23 + specs/studio-skill-document/README.zh.md | 23 + specs/studio-tool-activity/README.md | 124 + specs/studio-tool-activity/README.zh.md | 124 + tests/auth/test_oauth2_auth.py | 105 + tests/cli/test_cli_mpa.py | 685 ++++++ tests/cli/test_cli_mpa_control.py | 765 ++++++ tests/cli/test_frontend_apmplus_trace.py | 20 +- tests/cli/test_frontend_deploy_iam.py | 86 + tests/cli/test_frontend_runtime_proxy.py | 1671 ++++++++++++- tests/cli/test_github_cicd.py | 309 +++ tests/cli/test_mpa_a2a_url.py | 74 + tests/cli/test_runtime_a2a_stream.py | 1271 ++++++++++ tests/cli/test_studio_dependencies.py | 74 + tests/cli/test_studio_deploy_target.py | 213 +- tests/cli/test_studio_rbac.py | 1067 ++++++++- tests/cli/test_studio_startup_imports.py | 7 +- tests/cli/test_studio_tos_sdk_dependency.py | 36 + tests/cli/test_studio_trace_pagination.py | 89 + tests/cli/test_studio_update.py | 29 +- .../extensions/harness/test_sidecar_wheel.py | 4 +- .../mpa/test_agent_operations_service.py | 320 +++ tests/frontend/server/mpa/test_mpa_module.py | 68 + .../server/mpa/test_operations_repository.py | 242 ++ .../server/mpa/test_runtime_profile_client.py | 340 +++ .../server/mpa/test_runtime_profile_routes.py | 969 ++++++++ .../server/mpa/test_scenario_harness.py | 1021 ++++++++ tests/frontend/server/skills/test_versions.py | 131 + tests/frontend/server/test_mpa_agent_info.py | 177 ++ tests/frontend/server/test_mpa_cron.py | 133 ++ .../server/test_mpa_identity_callback.py | 452 ++++ tests/frontend/server/test_runtime_logs.py | 26 + tests/integrations/mpa_managed/__init__.py | 13 + .../mpa_managed/fakes_deployment_network.py | 110 + .../mpa_managed/test_admin_database.py | 273 +++ .../mpa_managed/test_agent_deployment.py | 795 ++++++ .../integrations/mpa_managed/test_auto_pg.py | 806 +++++++ tests/integrations/mpa_managed/test_config.py | 586 +++++ .../mpa_managed/test_creation_images.py | 300 +++ .../test_deployment_database_unit.py | 384 +++ .../mpa_managed/test_deployment_network.py | 390 +++ .../test_deployment_network_cloud.py | 174 ++ .../integrations/mpa_managed/test_gateway.py | 292 +++ .../mpa_managed/test_gateway_cloud.py | 44 + .../mpa_managed/test_named_creation.py | 210 ++ .../mpa_managed/test_registry_url.py | 108 + tests/integrations/mpa_managed/test_routes.py | 189 ++ .../test_runtime_deployment_edges.py | 255 ++ .../integrations/mpa_managed/test_service.py | 609 +++++ .../test_studio_shared_resources.py | 146 ++ tests/integrations/mpa_managed/test_tags.py | 53 + tests/integrations/mpa_managed/test_tasks.py | 483 ++++ tests/integrations/mpa_managed/test_worker.py | 340 +++ .../mpa_managed/test_worker_metadata.py | 300 +++ .../mpa_managed/test_worker_recovery.py | 459 ++++ .../test_mpa_compatibility_manifest.py | 100 + .../test_mpa_control_plane_client.py | 605 +++++ tests/integrations/test_mpa_identity.py | 170 ++ tests/integrations/test_mpa_meta_seed.py | 240 ++ tests/integrations/test_mpa_p0_benchmark.py | 100 + tests/integrations/test_mpa_p0_e2e_runner.py | 485 ++++ tests/integrations/test_mpa_p0_vc21_driver.py | 377 +++ tests/integrations/test_mpa_provision_env.py | 275 +++ tests/integrations/test_mpa_runtime.py | 315 +++ tests/integrations/test_mpa_skill_space.py | 62 + tests/integrations/test_mpa_tool.py | 266 +++ tests/integrations/test_mpa_verify.py | 172 ++ .../test_ve_faas_deploy_image_key_auth.py | 156 ++ tests/scripts/test_scan_yaml_secrets.py | 68 + tests/test_mpa_channel_proxy_policy.py | 60 + tests/test_release_progress.py | 51 +- uv.lock | 2 +- veadk/auth/middleware/oauth2_auth.py | 24 +- veadk/cli/cli.py | 2 + veadk/cli/cli_frontend.py | 2127 ++++++++++++++++- veadk/cli/cli_mpa.py | 826 +++++++ veadk/cli/cli_mpa_admin.py | 96 + veadk/cli/cli_mpa_control.py | 617 +++++ veadk/cli/frontend_apmplus_trace.py | 235 +- veadk/cli/frontend_deploy_iam.py | 6 +- veadk/cli/frontend_deploy_policy.py | 25 +- veadk/cli/mpa_p0_contract.py | 163 ++ veadk/cli/runtime_a2a_stream.py | 890 +++++++ veadk/cli/studio_dependencies.py | 10 +- veadk/integrations/mpa/__init__.py | 43 + veadk/integrations/mpa/channel_proxy.py | 45 + .../integrations/mpa/control_plane_client.py | 555 +++++ veadk/integrations/mpa/managed/README.md | 232 ++ veadk/integrations/mpa/managed/README.zh.md | 228 ++ veadk/integrations/mpa/managed/__init__.py | 15 + .../mpa/managed/admin_database.py | 191 ++ veadk/integrations/mpa/managed/config.py | 741 ++++++ veadk/integrations/mpa/managed/credentials.py | 66 + veadk/integrations/mpa/managed/database.py | 377 +++ veadk/integrations/mpa/managed/diagnostics.py | 224 ++ veadk/integrations/mpa/managed/gateway.py | 180 ++ .../integrations/mpa/managed/gateway_cloud.py | 170 ++ veadk/integrations/mpa/managed/metadata.py | 208 ++ veadk/integrations/mpa/managed/network.py | 323 +++ .../integrations/mpa/managed/network_cloud.py | 139 ++ .../integrations/mpa/managed/pg_bootstrap.py | 335 +++ veadk/integrations/mpa/managed/pg_cloud.py | 366 +++ veadk/integrations/mpa/managed/registry.py | 142 ++ veadk/integrations/mpa/managed/runner.py | 83 + veadk/integrations/mpa/managed/runtime.py | 653 +++++ veadk/integrations/mpa/managed/service.py | 389 +++ veadk/integrations/mpa/managed/skills.py | 136 ++ .../mpa/managed/studio_profile.py | 93 + veadk/integrations/mpa/managed/tasks.py | 466 ++++ veadk/integrations/mpa/managed/worker.py | 315 +++ veadk/integrations/mpa/mpa_identity.py | 107 + veadk/integrations/mpa/mpa_meta_seed.py | 273 +++ veadk/integrations/mpa/mpa_provision.py | 268 +++ veadk/integrations/mpa/mpa_runtime.py | 364 +++ veadk/integrations/mpa/mpa_skill_space.py | 77 + veadk/integrations/mpa/mpa_tool.py | 288 +++ veadk/integrations/mpa/mpa_verify.py | 120 + veadk/integrations/mpa/tags.py | 63 + veadk/integrations/ve_faas/ve_faas.py | 113 +- .../ve_identity/identity_client.py | 119 +- veadk/webui/assets/app/index-BNPwMQXG.js | 1398 +++++++++++ veadk/webui/assets/app/index-BcpSnsFp.js | 1372 ----------- ...BQpKN2_Y.js => CodeDiffEditor-DA-6OJrJ.js} | 2 +- ...VzGB.js => FileExplorerEditor-B8Ub9k9b.js} | 2 +- ...G9.js => MarkdownPromptEditor-D6ctFDfi.js} | 2 +- ....js => QuickAgentCreateDialog-04pJjJQJ.js} | 2 +- .../{arc-CE0biH39.js => arc-fsSJ4XQX.js} | 2 +- ...hannel-CknSfctO.js => channel-Bbp-1bKw.js} | 2 +- ...ex.es-YSRnAMpa.js => index.es-JsJulYHK.js} | 2 +- ...n-CmNSIheP.js => jspdf.es.min-aFbI3tiR.js} | 6 +- ...{linear-D7YqIViL.js => linear-Dd6OAXqp.js} | 2 +- veadk/webui/assets/styles/index-CUN-lyX5.css | 10 - veadk/webui/assets/styles/index-_lutWX9f.css | 10 + ...en.js => abnfDiagram-N423BO3Z-RhYMe2zl.js} | 2 +- ... architectureDiagram-T3A2C74G-C47wDTji.js} | 2 +- ...0.js => blockDiagram-VBNYF7ZC-BAJbaVfQ.js} | 2 +- ...gw8b.js => c4Diagram-5PPSVZJV-CJepdaIg.js} | 2 +- ...Dbxo18XS.js => chunk-2GRJ4B5K-CvII-2ZL.js} | 2 +- ...DWO0x-gt.js => chunk-2Q5K7J3B-nvFjhS5b.js} | 2 +- ...BThWW3Mz.js => chunk-5RXB4S5H-CY1YEJD3.js} | 2 +- ...spyYz9_w.js => chunk-5VM5RSS4-BDBKcK2Z.js} | 2 +- ...D2AB22Qb.js => chunk-6Q2QTUOP-zEQK1XLO.js} | 2 +- ...WqpbbvGd.js => chunk-GF5L2VYU-Cv1ZgaH8.js} | 2 +- ...saJxckiD.js => chunk-JWPE2WC7-DD-BFVC6.js} | 2 +- ...COhTnu0H.js => chunk-KBJHAD2P-DtNDDRN1.js} | 2 +- ...DYejWv7S.js => chunk-RYQCIY6F-DL3iLUXF.js} | 2 +- ...1cbgwOld.js => chunk-XXDRQBXY-B8qTF1qQ.js} | 2 +- .../mermaid/classDiagram-JCYQIIEL-DSyq-_Sn.js | 1 - .../mermaid/classDiagram-JCYQIIEL-DtPkX3wA.js | 1 + .../classDiagram-v2-OCEON4UE-DSyq-_Sn.js | 1 - .../classDiagram-v2-OCEON4UE-DtPkX3wA.js | 1 + ...N.js => cose-bilkent-JH36ORCC-BNlKHYzG.js} | 2 +- ...IfZIRn.js => cynefin-OW5HDTMX-DBq9B5VP.js} | 2 +- ...js => cynefinDiagram-MW4NZA55-DScg9_Ly.js} | 2 +- ...C1L5FQ8P.js => dagre-VZM6K2ZE-shxLHn3p.js} | 2 +- ...2YFcSV.js => diagram-7IWD3JNH-C3mTV4cL.js} | 2 +- ...r1x0nn.js => diagram-B4RE2ZJO-D66216sR.js} | 2 +- ...s8ciIO.js => diagram-LBJQPF4R-BZA2jWUX.js} | 2 +- ...vOmh8B.js => diagram-Q27KOJAE-Cpjz8RT3.js} | 2 +- ...0y6jJ-.js => diagram-UB23O5K3-2zSFzlV6.js} | 2 +- ...OR.js => ebnfDiagram-BXEA7PRR-_XPQYYfQ.js} | 2 +- ...5efF.js => erDiagram-JOGREHBK-CKTuwmcS.js} | 2 +- ...Gr.js => flowDiagram-UKHOOZJN-BtIca__0.js} | 2 +- ...-.js => ganttDiagram-PKOTCBZU-BsTpL-yb.js} | 2 +- ...s => gitGraphDiagram-DS77QQ5N-DupWgFfq.js} | 2 +- ...uy.js => infoDiagram-6WML65LV-Dl6zW5rI.js} | 2 +- ...s => ishikawaDiagram-WSZJBQD7-NBIjIwSA.js} | 2 +- ...js => journeyDiagram-NVQOT4AX-D1RTQQch.js} | 2 +- ...=> kanban-definition-27J2QSJJ-C8voMCH4.js} | 2 +- ...e-Bi7HkArr.js => mermaid.core-y4TF8bTw.js} | 8 +- ...> mindmap-definition-FAOFIHXS-oi9y3HSE.js} | 2 +- ...TmZ.js => pegDiagram-VL7TDLO6-CYzndrT-.js} | 2 +- ...1Wx.js => pieDiagram-7S7Q4E2Y-B1kVpjEf.js} | 2 +- ...s => quadrantDiagram-CIZ2JOQS-Ce1mswm1.js} | 2 +- ...s => railroadDiagram-AXF67PYL-VReUdmSC.js} | 2 +- ...> requirementDiagram-LRYGKXZP-BMkVtH2s.js} | 2 +- ....js => sankeyDiagram-W5VNT64P-gHIvLiGA.js} | 2 +- ...s => sequenceDiagram-SI44F4Z6-D6lfXAnD.js} | 2 +- ...N6.js => sizeCapture-X5ZJPWSS-DguhcNs8.js} | 2 +- ...p.js => stateDiagram-OKZ733FA-CPFdYVIx.js} | 2 +- .../stateDiagram-v2-UEYNNEHI-CBC6HwPm.js | 1 + .../stateDiagram-v2-UEYNNEHI-D7MXfWGS.js | 1 - ...g1pE.js => swimlanes-SLNWSIFB-DEO6uzPS.js} | 4 +- .../swimlanesDiagram-ULZ7WXOC-C7KIOtds.js | 8 - .../swimlanesDiagram-ULZ7WXOC-DIP88zHc.js | 8 + ... timeline-definition-Z64GVDOM-CKcwRGgc.js} | 2 +- ...kd.js => vennDiagram-T6HMQDX7-DpGQcsH1.js} | 2 +- ...js => wardleyDiagram-T6FBY63Y-CUlxVcoQ.js} | 2 +- ...js => xychartDiagram-ELKLHX3M-Cqf4o1Vh.js} | 2 +- ...{style-LQeYgvcd.css => style-DQO54G3J.css} | 2 +- veadk/webui/index.html | 4 +- veadk/webui/website-integration.js | 1228 +++++----- 608 files changed, 83897 insertions(+), 3760 deletions(-) delete mode 100644 .agents/plugins/marketplace.json create mode 100644 .trufflehog-exclude-paths.txt create mode 100644 contracts/mpa-p0/benchmark-report.schema.json create mode 100644 contracts/mpa-p0/compatibility-matrix.json create mode 100644 frontend/server/mpa/__init__.py create mode 100644 frontend/server/mpa/operations.py create mode 100644 frontend/server/mpa/routes.py create mode 100644 frontend/server/mpa/runtime_client.py create mode 100644 frontend/server/mpa/service.py create mode 100644 frontend/server/mpa/test_scenarios.py create mode 100644 frontend/server/mpa_a2a.py create mode 100644 frontend/server/mpa_agent_info.py create mode 100644 frontend/server/mpa_creation.py create mode 100644 frontend/server/mpa_cron.py create mode 100644 frontend/server/mpa_identity_callback.py create mode 100644 frontend/server/skills/documents.py create mode 100644 frontend/src/adk/channels.test.ts create mode 100644 frontend/src/adk/mpa-identity-prewarm.test.ts create mode 100644 frontend/src/adk/mpaCreation.ts create mode 100644 frontend/src/adk/mpaCreationImages.ts create mode 100644 frontend/src/adk/mpaCreationResources.ts create mode 100644 frontend/src/adk/mpaCronTasks.ts create mode 100644 frontend/src/adk/skillDocuments.ts create mode 100644 frontend/src/adk/wecom-sdk.d.ts create mode 100644 frontend/src/adk/wecomAuthorization.ts create mode 100644 frontend/src/automations/mpa-channels/MpaChannelsAutomation.css create mode 100644 frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx create mode 100644 frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx create mode 100644 frontend/src/automations/mpaChannels.ts create mode 100644 frontend/src/cronjobs/MpaCronTasks.css create mode 100644 frontend/src/cronjobs/MpaCronTasks.tsx create mode 100644 frontend/src/cronjobs/MpaTaskDetail.tsx create mode 100644 frontend/src/cronjobs/MpaTaskEditor.tsx create mode 100644 frontend/src/cronjobs/MpaTaskIcons.tsx create mode 100644 frontend/src/cronjobs/mpaSchedule.ts create mode 100644 frontend/src/transcriptReconcile.ts create mode 100644 frontend/src/ui/RuntimeChannels.css create mode 100644 frontend/src/ui/RuntimeChannels.tsx create mode 100644 frontend/src/ui/SandboxFileLink.tsx create mode 100644 frontend/src/ui/mpa-agent-info/MpaAgentInfoRail.tsx create mode 100644 frontend/src/ui/mpa-agent-info/SkillDocumentEditor.css create mode 100644 frontend/src/ui/mpa-agent-info/SkillDocumentEditor.tsx create mode 100644 frontend/src/ui/mpa-create/MpaCreateDialog.css create mode 100644 frontend/src/ui/mpa-create/MpaCreateDialog.tsx create mode 100644 frontend/src/ui/tool-activity/ToolActivityCard.tsx create mode 100644 frontend/src/ui/tool-activity/ToolActivityIcons.tsx create mode 100644 frontend/src/ui/tool-activity/model.ts create mode 100644 frontend/src/ui/tool-activity/tool-activity.css create mode 100644 frontend/tests/a2aWaiting.test.tsx create mode 100644 frontend/tests/mpaA2aTranscript.test.mjs create mode 100644 frontend/tests/mpaAgentInfoRail.test.tsx create mode 100644 frontend/tests/mpaChannelsAutomation.test.tsx create mode 100644 frontend/tests/mpaContentPreservation.test.mjs create mode 100644 frontend/tests/mpaCreation.test.tsx create mode 100644 frontend/tests/mpaCronTasks.test.tsx create mode 100644 frontend/tests/mpaManagement.test.tsx create mode 100644 frontend/tests/mpaResponseGrouping.test.mjs create mode 100644 frontend/tests/mpaSessionConfigUi.test.mjs create mode 100644 frontend/tests/mpaSessionExecutionConfigClient.test.mjs create mode 100644 frontend/tests/mpaSessionProtocol.test.mjs create mode 100644 frontend/tests/runtimeChannels.test.tsx create mode 100644 frontend/tests/runtimeControlAndEnvironmentState.test.mjs create mode 100644 frontend/tests/sandboxDownload.test.tsx create mode 100644 frontend/tests/sandboxDownloadClient.test.ts create mode 100644 frontend/tests/skillDocumentEditor.test.tsx create mode 100644 frontend/tests/toolActivityModel.test.mjs create mode 100644 frontend/vitest.a2a-wait.config.ts create mode 100644 frontend/vitest.mpa-cron.config.ts create mode 100644 frontend/vitest.sandbox-download.config.ts create mode 100644 openspec/changes/mpa-agent-studio-a2a-streaming/proposal.md create mode 100644 openspec/changes/mpa-agent-studio-a2a-streaming/tasks.md create mode 100644 openspec/changes/mpa-p0-productionization/proposal.md create mode 100644 openspec/changes/mpa-p0-productionization/tasks.md create mode 100644 openspec/changes/mpa-runtime-integration-hardening/proposal.md create mode 100644 openspec/changes/mpa-runtime-integration-hardening/tasks.md create mode 100644 prd-spec/README.md create mode 100644 prd-spec/README.zh.md create mode 100644 prd-spec/bugfixes/2026-09-12-a2a-first-event-heartbeat.md create mode 100644 prd-spec/bugfixes/2026-09-12-a2a-first-event-heartbeat.zh.md create mode 100644 prd-spec/bugfixes/2026-09-23-aidap-workspace-readiness.md create mode 100644 prd-spec/bugfixes/2026-09-23-aidap-workspace-readiness.zh.md create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-a2a-discovery-default.md create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-a2a-discovery-default.zh.md create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-registry-tls-url.md create mode 100644 prd-spec/bugfixes/2026-09-23-mpa-registry-tls-url.zh.md create mode 100644 prd-spec/bugfixes/a2a-long-running/2026-09-15-wait.md create mode 100644 prd-spec/bugfixes/a2a-long-running/2026-09-15-wait.zh.md create mode 100644 prd-spec/bugfixes/channel-group-actions/2026-09-15-channel-group-actions.md create mode 100644 prd-spec/bugfixes/channel-group-actions/2026-09-15-channel-group-actions.zh.md create mode 100644 prd-spec/bugfixes/channel-qr-lifetime/2026-09-15-channel-qr-lifetime.md create mode 100644 prd-spec/bugfixes/channel-qr-lifetime/2026-09-15-channel-qr-lifetime.zh.md create mode 100644 prd-spec/bugfixes/chat-model-dropdown/2026-09-14-chat-model-dropdown.md create mode 100644 prd-spec/bugfixes/chat-model-dropdown/2026-09-14-chat-model-dropdown.zh.md create mode 100644 prd-spec/bugfixes/history-turn-identity/2026-09-17-replay.md create mode 100644 prd-spec/bugfixes/history-turn-identity/2026-09-17-replay.zh.md create mode 100644 prd-spec/bugfixes/mpa-agent-filtering/2026-09-14-mpa-agent-filtering.md create mode 100644 prd-spec/bugfixes/mpa-agent-filtering/2026-09-14-mpa-agent-filtering.zh.md create mode 100644 prd-spec/bugfixes/mpa-channel-visibility/2026-09-15-mpa-channel-visibility.md create mode 100644 prd-spec/bugfixes/mpa-channel-visibility/2026-09-15-mpa-channel-visibility.zh.md create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-explicit-new-request.md create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-explicit-new-request.zh.md create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-recover-step-navigation.md create mode 100644 prd-spec/bugfixes/mpa-create-navigation/2026-09-30-recover-step-navigation.zh.md create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-15-default-jwt.md create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-15-default-jwt.zh.md create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-30-studio-key-auth-default.md create mode 100644 prd-spec/bugfixes/mpa-default-jwt/2026-09-30-studio-key-auth-default.zh.md create mode 100644 prd-spec/bugfixes/mpa-managed-workload-identity/2026-09-29-ensure-managed-workload-identity.md create mode 100644 prd-spec/bugfixes/mpa-managed-workload-identity/2026-09-29-ensure-managed-workload-identity.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-fresh-command/2026-09-29-set-fresh-runtime-command.md create mode 100644 prd-spec/bugfixes/mpa-runtime-fresh-command/2026-09-29-set-fresh-runtime-command.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-initial-tool/2026-09-29-runtime-initial-tool-attachment.md create mode 100644 prd-spec/bugfixes/mpa-runtime-initial-tool/2026-09-29-runtime-initial-tool-attachment.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-12-mpa-runtime-integration-hardening.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-12-mpa-runtime-integration-hardening.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-execution.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-execution.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-review.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-dev-loop-review.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-functional-validation-cases.md create mode 100644 prd-spec/bugfixes/mpa-runtime-integration/2026-09-13-functional-validation-cases.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-mcp-secret/2026-10-07-persistent-mcp-secret.md create mode 100644 prd-spec/bugfixes/mpa-runtime-mcp-secret/2026-10-07-persistent-mcp-secret.zh.md create mode 100644 prd-spec/bugfixes/mpa-runtime-reference-command/2026-09-29-preserve-runtime-command.md create mode 100644 prd-spec/bugfixes/mpa-runtime-reference-command/2026-09-29-preserve-runtime-command.zh.md create mode 100644 prd-spec/bugfixes/mpa-sandbox-event-identity/2026-09-29-typed-event-dedup.md create mode 100644 prd-spec/bugfixes/mpa-sandbox-event-identity/2026-09-29-typed-event-dedup.zh.md create mode 100644 prd-spec/bugfixes/mpa-shared-network-bootstrap/2026-09-28-registry-owned-network.md create mode 100644 prd-spec/bugfixes/mpa-shared-network-bootstrap/2026-09-28-registry-owned-network.zh.md create mode 100644 prd-spec/bugfixes/mpa-test-types/2026-09-21-mpa-test-types.md create mode 100644 prd-spec/bugfixes/mpa-test-types/2026-09-21-mpa-test-types.zh.md create mode 100644 prd-spec/bugfixes/mpa-worker-naming/2026-09-28-agent-derived-worker-name.md create mode 100644 prd-spec/bugfixes/mpa-worker-naming/2026-09-28-agent-derived-worker-name.zh.md create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-metadata-visibility.md create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-metadata-visibility.zh.md create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-retry.md create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-20-worker-retry.zh.md create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-30-worker-metadata-deadline.md create mode 100644 prd-spec/bugfixes/mpa-worker-retry/2026-09-30-worker-metadata-deadline.zh.md create mode 100644 prd-spec/bugfixes/runtime-disable-db-spans/2026-09-17-default-db-spans.md create mode 100644 prd-spec/bugfixes/runtime-disable-db-spans/2026-09-17-default-db-spans.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-final.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-final.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-reasoning.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-a2a-reasoning.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-direct-answer-reasoning.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-direct-answer-reasoning.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-reasoning-snapshots.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-reasoning-snapshots.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-wrapper-preview.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-mpa-wrapper-preview.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-followup-content.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-followup-content.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-sandbox-reasoning.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-preserve-sandbox-reasoning.zh.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-studio-codex-commentary-dedup.md create mode 100644 prd-spec/bugfixes/studio-codex-commentary-dedup/2026-09-22-studio-codex-commentary-dedup.zh.md create mode 100644 prd-spec/bugfixes/studio-cron-e2e/2026-09-15-integration.md create mode 100644 prd-spec/bugfixes/studio-cron-e2e/2026-09-15-integration.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-a2a-preference/2026-09-30-prefer-a2a.md create mode 100644 prd-spec/bugfixes/studio-mpa-a2a-preference/2026-09-30-prefer-a2a.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-aidap-runtime-policy.md create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-aidap-runtime-policy.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-mpa-cloud-runtime-policy.md create mode 100644 prd-spec/bugfixes/studio-mpa-aidap-permissions/2026-09-23-complete-mpa-cloud-runtime-policy.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-artifact-entry/2026-09-30-hide-artifact-entry.md create mode 100644 prd-spec/bugfixes/studio-mpa-artifact-entry/2026-09-30-hide-artifact-entry.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-creation/2026-09-24-start-another-agent-after-failure.md create mode 100644 prd-spec/bugfixes/studio-mpa-creation/2026-09-24-start-another-agent-after-failure.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-dialog-theme/2026-09-20-dialog-theme.md create mode 100644 prd-spec/bugfixes/studio-mpa-dialog-theme/2026-09-20-dialog-theme.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-profile-errors/2026-09-20-studio-mpa-profile-errors.md create mode 100644 prd-spec/bugfixes/studio-mpa-profile-errors/2026-09-20-studio-mpa-profile-errors.zh.md create mode 100644 prd-spec/bugfixes/studio-mpa-writable-state/2026-09-23-use-temporary-state.md create mode 100644 prd-spec/bugfixes/studio-mpa-writable-state/2026-09-23-use-temporary-state.zh.md create mode 100644 prd-spec/bugfixes/studio-openviking-wheel/2026-10-07-align-sdk-wheel.md create mode 100644 prd-spec/bugfixes/studio-openviking-wheel/2026-10-07-align-sdk-wheel.zh.md create mode 100644 prd-spec/bugfixes/studio-session-protocol/2026-09-22-a2a-session-routing.md create mode 100644 prd-spec/bugfixes/studio-session-protocol/2026-09-22-a2a-session-routing.zh.md create mode 100644 prd-spec/bugfixes/studio-startup-assets/2026-09-22-studio-startup-assets.md create mode 100644 prd-spec/bugfixes/studio-startup-assets/2026-09-22-studio-startup-assets.zh.md create mode 100644 prd-spec/bugfixes/studio-trace-pagination/2026-09-17-session-trace.md create mode 100644 prd-spec/bugfixes/studio-trace-pagination/2026-09-17-session-trace.zh.md create mode 100644 prd-spec/bugfixes/studio-webui-build-integrity/2026-09-23-rebuild-corrupted-assets.md create mode 100644 prd-spec/bugfixes/studio-webui-build-integrity/2026-09-23-rebuild-corrupted-assets.zh.md create mode 100644 prd-spec/bugfixes/yaml-secret-guard/2026-09-23-yaml-secret-guard.md create mode 100644 prd-spec/bugfixes/yaml-secret-guard/2026-09-23-yaml-secret-guard.zh.md create mode 100644 prd-spec/features/channel-auth-methods/2026-09-15-channel-auth-methods.md create mode 100644 prd-spec/features/channel-auth-methods/2026-09-15-channel-auth-methods.zh.md create mode 100644 prd-spec/features/channel-configuration-modes/2026-09-15-channel-configuration-modes.md create mode 100644 prd-spec/features/channel-configuration-modes/2026-09-15-channel-configuration-modes.zh.md create mode 100644 prd-spec/features/channel-qr-regeneration/2026-09-15-channel-qr-regeneration.md create mode 100644 prd-spec/features/channel-qr-regeneration/2026-09-15-channel-qr-regeneration.zh.md create mode 100644 prd-spec/features/message-channel-binding/2026-09-15-message-channel-binding.md create mode 100644 prd-spec/features/message-channel-binding/2026-09-15-message-channel-binding.zh.md create mode 100644 prd-spec/features/message-channel-workspace/2026-09-15-message-channel-workspace.md create mode 100644 prd-spec/features/message-channel-workspace/2026-09-15-message-channel-workspace.zh.md create mode 100644 prd-spec/features/mpa-agent-info-rail/2026-09-18-mpa-agent-info-rail.md create mode 100644 prd-spec/features/mpa-agent-info-rail/2026-09-18-mpa-agent-info-rail.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-10-mpa-agent-oneclick-provision-design.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-10-mpa-agent-oneclick-provision-design.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-oneclick-provision-verification-cases.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-oneclick-provision-verification-cases.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-provision-orchestration-alignment-design.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-11-mpa-agent-provision-orchestration-alignment-design.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-design.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-design.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-verification-cases.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-12-mpa-agent-studio-a2a-streaming-verification-cases.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-20-studio-mpa-creation.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-20-studio-mpa-creation.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-mpa-tos-output-mount.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-mpa-tos-output-mount.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-builtin-mpa-profile.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-builtin-mpa-profile.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-openviking-key.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-openviking-key.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-three-step-creation.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-09-23-studio-mpa-three-step-creation.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-10-09-named-studio-creation.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/2026-10-09-named-studio-creation.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/evidence/2026-09-11-code-review.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/evidence/2026-09-11-verification-execution.zh.md create mode 100644 prd-spec/features/mpa-agent-oneclick-provision/mpa-create.config.example.yaml create mode 100644 prd-spec/features/mpa-channels-automation/2026-09-16-mpa-channels-automation.md create mode 100644 prd-spec/features/mpa-channels-automation/2026-09-16-mpa-channels-automation.zh.md create mode 100644 prd-spec/features/mpa-feishu-channels/2026-09-15-mpa-feishu-channels.md create mode 100644 prd-spec/features/mpa-feishu-channels/2026-09-15-mpa-feishu-channels.zh.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-dev-loop-spec-review.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-dev-loop-spec-review.zh.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-functional-validation-cases.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-functional-validation-cases.zh.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-m0-evidence.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-m0-evidence.zh.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-mpa-p0-productionization-design.md create mode 100644 prd-spec/features/mpa-p0-productionization/2026-09-15-mpa-p0-productionization-design.zh.md create mode 100644 prd-spec/features/mpa-p0-productionization/browser-cases.md create mode 100644 prd-spec/features/mpa-p0-productionization/browser-cases.zh.md create mode 100644 prd-spec/features/mpa-response-grouping/2026-09-22-mpa-response-grouping.md create mode 100644 prd-spec/features/mpa-response-grouping/2026-09-22-mpa-response-grouping.zh.md create mode 100644 prd-spec/features/mpa-serverless-pg/2026-09-21-deployment-account-pg.md create mode 100644 prd-spec/features/mpa-serverless-pg/2026-09-21-deployment-account-pg.zh.md create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-auto-pg-workspaces.md create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-auto-pg-workspaces.zh.md create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-space-scoped-manual-pg.md create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-space-scoped-manual-pg.zh.md create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-two-pg-workspaces.md create mode 100644 prd-spec/features/mpa-space-scoped-resources/2026-09-23-two-pg-workspaces.zh.md create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-design.md create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-design.zh.md create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-implementation-plan.md create mode 100644 prd-spec/features/mpa-studio-control-plane/2026-09-13-mpa-studio-control-plane-implementation-plan.zh.md create mode 100644 prd-spec/features/mpa-studio-workload-identity/2026-09-20-mpa-studio-workload-identity.md create mode 100644 prd-spec/features/mpa-studio-workload-identity/2026-09-20-mpa-studio-workload-identity.zh.md create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-mono-task-management.md create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-mono-task-management.zh.md create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-remove-agent-input.md create mode 100644 prd-spec/features/studio-mpa-cron-tasks/2026-09-15-remove-agent-input.zh.md create mode 100644 prd-spec/features/studio-mpa-cron-tasks/README.md create mode 100644 prd-spec/features/studio-mpa-cron-tasks/README.zh.md create mode 100644 prd-spec/features/studio-mpa-identity-reuse/2026-09-23-deploy-identity-reuse.md create mode 100644 prd-spec/features/studio-mpa-identity-reuse/2026-09-23-deploy-identity-reuse.zh.md create mode 100644 prd-spec/features/studio-mpa-login/2026-09-23-studio-mpa-prewarm.md create mode 100644 prd-spec/features/studio-mpa-login/2026-09-23-studio-mpa-prewarm.zh.md create mode 100644 prd-spec/features/studio-mpa-main-integration/2026-09-20-main-integration.md create mode 100644 prd-spec/features/studio-mpa-main-integration/2026-09-20-main-integration.zh.md create mode 100644 prd-spec/features/studio-mpa-source-filter/2026-09-28-studio-mpa-source-filter.md create mode 100644 prd-spec/features/studio-mpa-source-filter/2026-09-28-studio-mpa-source-filter.zh.md create mode 100644 prd-spec/features/studio-oidc-id-token/2026-09-23-retain-id-token.md create mode 100644 prd-spec/features/studio-oidc-id-token/2026-09-23-retain-id-token.zh.md create mode 100644 prd-spec/features/studio-sandbox-download/2026-09-17-history-download.md create mode 100644 prd-spec/features/studio-sandbox-download/2026-09-17-history-download.zh.md create mode 100644 prd-spec/features/studio-tool-activity-visualization/2026-09-12-studio-tool-activity-visualization-design.md create mode 100644 prd-spec/features/studio-tool-activity-visualization/2026-09-12-studio-tool-activity-visualization-design.zh.md create mode 100755 scripts/benchmark-mpa-p0.py create mode 100644 scripts/mpa_p0_contract.py create mode 100644 scripts/mpa_p0_e2e.py create mode 100755 scripts/mpa_p0_vc21_driver.py create mode 100755 scripts/run-mpa-p0-browser-fixture.sh create mode 100755 scripts/scan_yaml_secrets.py create mode 100755 scripts/verify-mpa-p0-contract.sh create mode 100755 scripts/verify-mpa-p0-e2e.sh create mode 100644 specs/README.md create mode 100644 specs/README.zh.md create mode 100644 specs/mpa-channels/README.md create mode 100644 specs/mpa-channels/README.zh.md create mode 100644 specs/mpa-runtime-control/README.md create mode 100644 specs/mpa-runtime-control/README.zh.md create mode 100644 specs/mpa-runtime-provisioning/README.md create mode 100644 specs/mpa-runtime-provisioning/README.zh.md create mode 100644 specs/studio-mpa-control-plane/README.md create mode 100644 specs/studio-mpa-control-plane/README.zh.md create mode 100644 specs/studio-mpa-creation/README.md create mode 100644 specs/studio-mpa-creation/README.zh.md create mode 100644 specs/studio-mpa-cron-tasks/README.md create mode 100644 specs/studio-mpa-cron-tasks/README.zh.md create mode 100644 specs/studio-mpa-prewarm/README.md create mode 100644 specs/studio-mpa-prewarm/README.zh.md create mode 100644 specs/studio-oauth-session/README.md create mode 100644 specs/studio-oauth-session/README.zh.md create mode 100644 specs/studio-runtime-diagnostics/README.md create mode 100644 specs/studio-runtime-diagnostics/README.zh.md create mode 100644 specs/studio-sandbox-download/README.md create mode 100644 specs/studio-sandbox-download/README.zh.md create mode 100644 specs/studio-skill-document/README.md create mode 100644 specs/studio-skill-document/README.zh.md create mode 100644 specs/studio-tool-activity/README.md create mode 100644 specs/studio-tool-activity/README.zh.md create mode 100644 tests/cli/test_cli_mpa.py create mode 100644 tests/cli/test_cli_mpa_control.py create mode 100644 tests/cli/test_mpa_a2a_url.py create mode 100644 tests/cli/test_runtime_a2a_stream.py create mode 100644 tests/cli/test_studio_dependencies.py create mode 100644 tests/cli/test_studio_tos_sdk_dependency.py create mode 100644 tests/cli/test_studio_trace_pagination.py create mode 100644 tests/frontend/server/mpa/test_agent_operations_service.py create mode 100644 tests/frontend/server/mpa/test_mpa_module.py create mode 100644 tests/frontend/server/mpa/test_operations_repository.py create mode 100644 tests/frontend/server/mpa/test_runtime_profile_client.py create mode 100644 tests/frontend/server/mpa/test_runtime_profile_routes.py create mode 100644 tests/frontend/server/mpa/test_scenario_harness.py create mode 100644 tests/frontend/server/test_mpa_agent_info.py create mode 100644 tests/frontend/server/test_mpa_cron.py create mode 100644 tests/frontend/server/test_mpa_identity_callback.py create mode 100644 tests/integrations/mpa_managed/__init__.py create mode 100644 tests/integrations/mpa_managed/fakes_deployment_network.py create mode 100644 tests/integrations/mpa_managed/test_admin_database.py create mode 100644 tests/integrations/mpa_managed/test_agent_deployment.py create mode 100644 tests/integrations/mpa_managed/test_auto_pg.py create mode 100644 tests/integrations/mpa_managed/test_config.py create mode 100644 tests/integrations/mpa_managed/test_creation_images.py create mode 100644 tests/integrations/mpa_managed/test_deployment_database_unit.py create mode 100644 tests/integrations/mpa_managed/test_deployment_network.py create mode 100644 tests/integrations/mpa_managed/test_deployment_network_cloud.py create mode 100644 tests/integrations/mpa_managed/test_gateway.py create mode 100644 tests/integrations/mpa_managed/test_gateway_cloud.py create mode 100644 tests/integrations/mpa_managed/test_named_creation.py create mode 100644 tests/integrations/mpa_managed/test_registry_url.py create mode 100644 tests/integrations/mpa_managed/test_routes.py create mode 100644 tests/integrations/mpa_managed/test_runtime_deployment_edges.py create mode 100644 tests/integrations/mpa_managed/test_service.py create mode 100644 tests/integrations/mpa_managed/test_studio_shared_resources.py create mode 100644 tests/integrations/mpa_managed/test_tags.py create mode 100644 tests/integrations/mpa_managed/test_tasks.py create mode 100644 tests/integrations/mpa_managed/test_worker.py create mode 100644 tests/integrations/mpa_managed/test_worker_metadata.py create mode 100644 tests/integrations/mpa_managed/test_worker_recovery.py create mode 100644 tests/integrations/test_mpa_compatibility_manifest.py create mode 100644 tests/integrations/test_mpa_control_plane_client.py create mode 100644 tests/integrations/test_mpa_identity.py create mode 100644 tests/integrations/test_mpa_meta_seed.py create mode 100644 tests/integrations/test_mpa_p0_benchmark.py create mode 100644 tests/integrations/test_mpa_p0_e2e_runner.py create mode 100644 tests/integrations/test_mpa_p0_vc21_driver.py create mode 100644 tests/integrations/test_mpa_provision_env.py create mode 100644 tests/integrations/test_mpa_runtime.py create mode 100644 tests/integrations/test_mpa_skill_space.py create mode 100644 tests/integrations/test_mpa_tool.py create mode 100644 tests/integrations/test_mpa_verify.py create mode 100644 tests/integrations/test_ve_faas_deploy_image_key_auth.py create mode 100644 tests/scripts/test_scan_yaml_secrets.py create mode 100644 tests/test_mpa_channel_proxy_policy.py create mode 100644 veadk/cli/cli_mpa.py create mode 100644 veadk/cli/cli_mpa_admin.py create mode 100644 veadk/cli/cli_mpa_control.py create mode 100644 veadk/cli/mpa_p0_contract.py create mode 100644 veadk/cli/runtime_a2a_stream.py create mode 100644 veadk/integrations/mpa/__init__.py create mode 100644 veadk/integrations/mpa/channel_proxy.py create mode 100644 veadk/integrations/mpa/control_plane_client.py create mode 100644 veadk/integrations/mpa/managed/README.md create mode 100644 veadk/integrations/mpa/managed/README.zh.md create mode 100644 veadk/integrations/mpa/managed/__init__.py create mode 100644 veadk/integrations/mpa/managed/admin_database.py create mode 100644 veadk/integrations/mpa/managed/config.py create mode 100644 veadk/integrations/mpa/managed/credentials.py create mode 100644 veadk/integrations/mpa/managed/database.py create mode 100644 veadk/integrations/mpa/managed/diagnostics.py create mode 100644 veadk/integrations/mpa/managed/gateway.py create mode 100644 veadk/integrations/mpa/managed/gateway_cloud.py create mode 100644 veadk/integrations/mpa/managed/metadata.py create mode 100644 veadk/integrations/mpa/managed/network.py create mode 100644 veadk/integrations/mpa/managed/network_cloud.py create mode 100644 veadk/integrations/mpa/managed/pg_bootstrap.py create mode 100644 veadk/integrations/mpa/managed/pg_cloud.py create mode 100644 veadk/integrations/mpa/managed/registry.py create mode 100644 veadk/integrations/mpa/managed/runner.py create mode 100644 veadk/integrations/mpa/managed/runtime.py create mode 100644 veadk/integrations/mpa/managed/service.py create mode 100644 veadk/integrations/mpa/managed/skills.py create mode 100644 veadk/integrations/mpa/managed/studio_profile.py create mode 100644 veadk/integrations/mpa/managed/tasks.py create mode 100644 veadk/integrations/mpa/managed/worker.py create mode 100644 veadk/integrations/mpa/mpa_identity.py create mode 100644 veadk/integrations/mpa/mpa_meta_seed.py create mode 100644 veadk/integrations/mpa/mpa_provision.py create mode 100644 veadk/integrations/mpa/mpa_runtime.py create mode 100644 veadk/integrations/mpa/mpa_skill_space.py create mode 100644 veadk/integrations/mpa/mpa_tool.py create mode 100644 veadk/integrations/mpa/mpa_verify.py create mode 100644 veadk/integrations/mpa/tags.py create mode 100644 veadk/webui/assets/app/index-BNPwMQXG.js delete mode 100644 veadk/webui/assets/app/index-BcpSnsFp.js rename veadk/webui/assets/chunks/{CodeDiffEditor-BQpKN2_Y.js => CodeDiffEditor-DA-6OJrJ.js} (99%) rename veadk/webui/assets/chunks/{FileExplorerEditor-D8Z1VzGB.js => FileExplorerEditor-B8Ub9k9b.js} (96%) rename veadk/webui/assets/chunks/{MarkdownPromptEditor-B5fLU-G9.js => MarkdownPromptEditor-D6ctFDfi.js} (99%) rename veadk/webui/assets/chunks/{QuickAgentCreateDialog-6X2BnBqv.js => QuickAgentCreateDialog-04pJjJQJ.js} (97%) rename veadk/webui/assets/chunks/{arc-CE0biH39.js => arc-fsSJ4XQX.js} (98%) rename veadk/webui/assets/chunks/{channel-CknSfctO.js => channel-Bbp-1bKw.js} (53%) rename veadk/webui/assets/chunks/{index.es-YSRnAMpa.js => index.es-JsJulYHK.js} (99%) rename veadk/webui/assets/chunks/{jspdf.es.min-CmNSIheP.js => jspdf.es.min-aFbI3tiR.js} (99%) rename veadk/webui/assets/chunks/{linear-D7YqIViL.js => linear-Dd6OAXqp.js} (98%) delete mode 100644 veadk/webui/assets/styles/index-CUN-lyX5.css create mode 100644 veadk/webui/assets/styles/index-_lutWX9f.css rename veadk/webui/assets/visualizations/mermaid/{abnfDiagram-N423BO3Z-C9m-vNen.js => abnfDiagram-N423BO3Z-RhYMe2zl.js} (86%) rename veadk/webui/assets/visualizations/mermaid/{architectureDiagram-T3A2C74G-DlHQ6hTt.js => architectureDiagram-T3A2C74G-C47wDTji.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{blockDiagram-VBNYF7ZC-Df9FWTd0.js => blockDiagram-VBNYF7ZC-BAJbaVfQ.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{c4Diagram-5PPSVZJV-h7qFgw8b.js => c4Diagram-5PPSVZJV-CJepdaIg.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2GRJ4B5K-Dbxo18XS.js => chunk-2GRJ4B5K-CvII-2ZL.js} (93%) rename veadk/webui/assets/visualizations/mermaid/{chunk-2Q5K7J3B-DWO0x-gt.js => chunk-2Q5K7J3B-nvFjhS5b.js} (66%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5RXB4S5H-BThWW3Mz.js => chunk-5RXB4S5H-CY1YEJD3.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-5VM5RSS4-spyYz9_w.js => chunk-5VM5RSS4-BDBKcK2Z.js} (83%) rename veadk/webui/assets/visualizations/mermaid/{chunk-6Q2QTUOP-D2AB22Qb.js => chunk-6Q2QTUOP-zEQK1XLO.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-GF5L2VYU-WqpbbvGd.js => chunk-GF5L2VYU-Cv1ZgaH8.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-JWPE2WC7-saJxckiD.js => chunk-JWPE2WC7-DD-BFVC6.js} (78%) rename veadk/webui/assets/visualizations/mermaid/{chunk-KBJHAD2P-COhTnu0H.js => chunk-KBJHAD2P-DtNDDRN1.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{chunk-RYQCIY6F-DYejWv7S.js => chunk-RYQCIY6F-DL3iLUXF.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{chunk-XXDRQBXY-1cbgwOld.js => chunk-XXDRQBXY-B8qTF1qQ.js} (53%) delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-DSyq-_Sn.js create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-JCYQIIEL-DtPkX3wA.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-DSyq-_Sn.js create mode 100644 veadk/webui/assets/visualizations/mermaid/classDiagram-v2-OCEON4UE-DtPkX3wA.js rename veadk/webui/assets/visualizations/mermaid/{cose-bilkent-JH36ORCC-BoIT7_DN.js => cose-bilkent-JH36ORCC-BNlKHYzG.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefin-OW5HDTMX-BbIfZIRn.js => cynefin-OW5HDTMX-DBq9B5VP.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{cynefinDiagram-MW4NZA55-C5jnTUMv.js => cynefinDiagram-MW4NZA55-DScg9_Ly.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{dagre-VZM6K2ZE-C1L5FQ8P.js => dagre-VZM6K2ZE-shxLHn3p.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-7IWD3JNH-B72YFcSV.js => diagram-7IWD3JNH-C3mTV4cL.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{diagram-B4RE2ZJO-MAr1x0nn.js => diagram-B4RE2ZJO-D66216sR.js} (97%) rename veadk/webui/assets/visualizations/mermaid/{diagram-LBJQPF4R-Rjs8ciIO.js => diagram-LBJQPF4R-BZA2jWUX.js} (94%) rename veadk/webui/assets/visualizations/mermaid/{diagram-Q27KOJAE-DwvOmh8B.js => diagram-Q27KOJAE-Cpjz8RT3.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{diagram-UB23O5K3-Bg0y6jJ-.js => diagram-UB23O5K3-2zSFzlV6.js} (95%) rename veadk/webui/assets/visualizations/mermaid/{ebnfDiagram-BXEA7PRR-D5w4LlOR.js => ebnfDiagram-BXEA7PRR-_XPQYYfQ.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{erDiagram-JOGREHBK-COvj5efF.js => erDiagram-JOGREHBK-CKTuwmcS.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{flowDiagram-UKHOOZJN-DidqPOGr.js => flowDiagram-UKHOOZJN-BtIca__0.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{ganttDiagram-PKOTCBZU-B_p1i0y-.js => ganttDiagram-PKOTCBZU-BsTpL-yb.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{gitGraphDiagram-DS77QQ5N-TSpq_MFk.js => gitGraphDiagram-DS77QQ5N-DupWgFfq.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{infoDiagram-6WML65LV-oYF6Pzuy.js => infoDiagram-6WML65LV-Dl6zW5rI.js} (69%) rename veadk/webui/assets/visualizations/mermaid/{ishikawaDiagram-WSZJBQD7-BDCXOoKk.js => ishikawaDiagram-WSZJBQD7-NBIjIwSA.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{journeyDiagram-NVQOT4AX-DWIPXEBZ.js => journeyDiagram-NVQOT4AX-D1RTQQch.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{kanban-definition-27J2QSJJ-Cm4a64NX.js => kanban-definition-27J2QSJJ-C8voMCH4.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{mermaid.core-Bi7HkArr.js => mermaid.core-y4TF8bTw.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{mindmap-definition-FAOFIHXS-B9xiPhGv.js => mindmap-definition-FAOFIHXS-oi9y3HSE.js} (98%) rename veadk/webui/assets/visualizations/mermaid/{pegDiagram-VL7TDLO6-CvPK3TmZ.js => pegDiagram-VL7TDLO6-CYzndrT-.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{pieDiagram-7S7Q4E2Y-BBEgW1Wx.js => pieDiagram-7S7Q4E2Y-B1kVpjEf.js} (96%) rename veadk/webui/assets/visualizations/mermaid/{quadrantDiagram-CIZ2JOQS-CSu8pZ53.js => quadrantDiagram-CIZ2JOQS-Ce1mswm1.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{railroadDiagram-AXF67PYL-BhMJCPkT.js => railroadDiagram-AXF67PYL-VReUdmSC.js} (84%) rename veadk/webui/assets/visualizations/mermaid/{requirementDiagram-LRYGKXZP-DOJBZSkq.js => requirementDiagram-LRYGKXZP-BMkVtH2s.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sankeyDiagram-W5VNT64P-B08R9g7X.js => sankeyDiagram-W5VNT64P-gHIvLiGA.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sequenceDiagram-SI44F4Z6-DSt-dqxP.js => sequenceDiagram-SI44F4Z6-D6lfXAnD.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{sizeCapture-X5ZJPWSS-BFuu49N6.js => sizeCapture-X5ZJPWSS-DguhcNs8.js} (87%) rename veadk/webui/assets/visualizations/mermaid/{stateDiagram-OKZ733FA-DC7wJZBp.js => stateDiagram-OKZ733FA-CPFdYVIx.js} (96%) create mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-CBC6HwPm.js delete mode 100644 veadk/webui/assets/visualizations/mermaid/stateDiagram-v2-UEYNNEHI-D7MXfWGS.js rename veadk/webui/assets/visualizations/mermaid/{swimlanes-SLNWSIFB-D-zZg1pE.js => swimlanes-SLNWSIFB-DEO6uzPS.js} (99%) delete mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-C7KIOtds.js create mode 100644 veadk/webui/assets/visualizations/mermaid/swimlanesDiagram-ULZ7WXOC-DIP88zHc.js rename veadk/webui/assets/visualizations/mermaid/{timeline-definition-Z64GVDOM-B_CXg3l7.js => timeline-definition-Z64GVDOM-CKcwRGgc.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{vennDiagram-T6HMQDX7-Dr36JOkd.js => vennDiagram-T6HMQDX7-DpGQcsH1.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{wardleyDiagram-T6FBY63Y-B8ov1MsF.js => wardleyDiagram-T6FBY63Y-CUlxVcoQ.js} (99%) rename veadk/webui/assets/visualizations/mermaid/{xychartDiagram-ELKLHX3M-C5Www8f1.js => xychartDiagram-ELKLHX3M-Cqf4o1Vh.js} (99%) rename veadk/webui/assets/widget/{style-LQeYgvcd.css => style-DQO54G3J.css} (67%) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json deleted file mode 100644 index ee49fa2d5..000000000 --- a/.agents/plugins/marketplace.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "veadk-python", - "interface": { - "displayName": "VeADK Python" - }, - "plugins": [ - { - "name": "agentkit-studio", - "source": { - "source": "local", - "path": "./plugins/agentkit-studio" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - } - ] -} diff --git a/.gitattributes b/.gitattributes index 8b5f008ca..2367cc7fb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ # Minified build artifacts can contain semantic whitespace inside template literals. veadk/webui/assets/**/*.js -whitespace +veadk/webui/website-integration.js -whitespace *.sh text eol=lf diff --git a/.github/workflows/secrets-scan.yaml b/.github/workflows/secrets-scan.yaml index 78454398c..c6a1728e9 100644 --- a/.github/workflows/secrets-scan.yaml +++ b/.github/workflows/secrets-scan.yaml @@ -32,4 +32,4 @@ jobs: - name: Secret Scanning uses: trufflesecurity/trufflehog@7dc056a193116ba8d82154bf0549381c8fb8545c # v3.88.14 with: - extra_args: --results=verified,unknown + extra_args: --results=verified,unknown --exclude-paths=.trufflehog-exclude-paths.txt diff --git a/.gitignore b/.gitignore index d0c2ee9e4..d13eb1d30 100644 --- a/.gitignore +++ b/.gitignore @@ -206,12 +206,31 @@ cython_debug/ .trae .claude .agents +/.dbg/ +/debug-*.md .adk/ **/.adk/ + +# Local AI/design and verification artifacts. Keep evidence summaries in +# PRD/spec documents, not raw screenshots, traces, manifests, or run logs. +/.artifacts/ +/.superpowers/ +/evidence/ + # AgentKit deploy config (contains account-specific secrets) agentkit.yaml agentkit*.yaml .agentkit/ +# veadk mpa create config (contains PG/model/OpenViking/Feishu secrets). +# Only the committed *.example.yaml template is allowed. +mpa-create.config.yaml +mpa-create.config*.yaml +!mpa-create.config.example.yaml +**/mpa-create.config.yaml +**/mpa-create.config*.yaml +!**/mpa-create.config.example.yaml + # PiAgent release archives are downloaded and verified during image builds. examples/piagent_with_mcp/vendor/pi-*.tar.gz +.gstack/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 45b030c2f..49f52c7d5 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -72,6 +72,11 @@ id = "empty-env-key-pattern" description = "Empty environment variables with KEY pattern" regex = '''os\.environ\[".*?KEY"\]\s*=\s*".+"''' +[[rules]] +id = "hardcoded-api-key-token-secret" +description = "Hardcoded API key, token, secret, password, or credential assignment" +regex = '''(?i)\b(api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|bearer[_-]?token|password|credential)\b["'\s:=]{0,16}["'][A-Za-z0-9_./+=:@%-]{16,}["']''' + [allowlist] # veadk/webui is the built (minified) web UI bundle — generated output reviewed # via its source under frontend/, not hand-written. Minified vendor libs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44d1a3dfc..0cd97794a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,4 +13,11 @@ repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.24.2 hooks: - - id: gitleaks \ No newline at end of file + - id: gitleaks +- repo: local + hooks: + - id: yaml-secret-scan + name: Detect concrete secrets in YAML + entry: scripts/scan_yaml_secrets.py + language: python + types_or: [yaml] diff --git a/.trufflehog-exclude-paths.txt b/.trufflehog-exclude-paths.txt new file mode 100644 index 000000000..798293f90 --- /dev/null +++ b/.trufflehog-exclude-paths.txt @@ -0,0 +1 @@ +^tests/integrations/mpa_managed/test_(admin_database|auto_pg|config|deployment_database_unit|registry_url|service)\.py$ diff --git a/AGENTS.md b/AGENTS.md index 68b82a6e0..71e4f2758 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,63 @@ # Agent Instructions -- Do not use `codex/` as a branch name prefix in this repository. Use semantic branch prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`; for example, `feat/pr-748-dev`. -- Before modifying frontend code, read `frontend/SPEC.md`. -- When the user describes a requirement, propose an implementation plan before changing code, and wait for the user to be satisfied before making edits. -- After creating or switching to a new branch, run `git pull` before development. Branch names must use semantic prefixes and reflect the requirement content. -- When committing, include only changes related to the current feature or fix. -- Before committing, fetch the latest remote code, rebase the branch onto it, then run pre-commit and unit tests. -- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall. +VeADK includes a Python SDK, CLI/runtime integrations, and AgentKit Studio with a TypeScript/React frontend and supporting Python services. Base decisions on the relevant implementation, tests, and existing contracts rather than assumptions from another repository. + +## Language and document ownership + +- Keep this repository-wide development standard in English. Use English for new code identifiers, comments, and docstrings; user-facing text follows the owning area's localization rules. +- Read [prd-spec/README.md](prd-spec/README.md) before creating or updating a change design. Every PRD/design must have complete English `.md` and Chinese `.zh.md` counterparts, sharing the same directory, date, and basename. +- Read [specs/README.md](specs/README.md) before defining or changing a component contract. Maintain each component under `specs//README.md` and `README.zh.md`. +- Keep both languages semantically equivalent and update them in the same change. Preserve identifiers, commands, examples, limits, and acceptance criteria across translations. Missing or conflicting counterparts block approval and delivery. +- PRDs own change-specific intent, scope, tasks, and acceptance; component specs own maintained responsibilities, interfaces, state, data, and failure semantics. Link them rather than duplicate contracts. +- Before modifying frontend code, read `frontend/SPEC.md`; also apply it to its supporting backend interfaces as specified there. It remains the Studio development standard and is not replaced or translated by this document. +- Preserve existing user-documentation conventions: the documentation site uses Chinese `.mdx` and English `.en.mdx`; bilingual module/example READMEs use English `README.md` and Chinese `README.zh.md`. + +## Required development workflow + +1. **Inspect and agree on scope.** Read the affected implementation, tests, and contracts. When the user describes a requirement, propose an implementation plan and wait for the user to be satisfied before making edits. Distinguish verified facts from assumptions. +2. **Design before implementation.** Features, refactors, and bugfixes require a bilingual PRD under `prd-spec/features/`, `prd-spec/refactors/`, or `prd-spec/bugfixes/`. Include background/evidence, goals, non-goals, scenarios, requirements, design, affected files, tasks, tests, risks, and acceptance criteria. +3. **Assess component contracts.** Create or update affected bilingual component specs for changes to ownership, APIs, configuration, state/data, events, permissions, security, compatibility, runtime behavior, or observability. Record a justified no-impact conclusion when no component contract changes. Do not backfill unrelated components. +4. **Clear design review.** Review PRD/spec consistency, feasibility, boundaries, errors, security, compatibility, testability, and bilingual equivalence. Use the available `review-spec` skill; if unavailable, perform and record the same review directly. Resolve blockers and record user approval before modifying production code or tests. A generic skill's Chinese-only default does not override this repository's bilingual rule. +5. **Implement against the approved design.** Map changes to requirements/tasks, write a failing regression or contract test first, then implement and refactor within scope. Contract or scope changes return to design review before implementation proceeds. +6. **Verify and review.** Run affected tests first, then required repository and component gates. Review both implementation correctness and edge cases, resource handling, security, test quality, and contract alignment. Fix blocking findings and rerun affected checks. +7. **Reconcile and deliver.** Synchronize both document languages, component contracts, affected user docs/examples, and required generated artifacts. Record actual verification commands, results, unverified areas, and remaining risks. All agreed tasks and acceptance criteria must be satisfied before declaring completion; deferred scope requires user agreement. + +Pure documentation, translation, or formatting changes that preserve runtime behavior and public contracts do not need a recursive PRD. They still require an agreed approach, applicable bilingual updates, and proportionate checks. This exception includes maintaining these development guidelines; it does not exempt code or behavioral changes. + +## Implementation boundaries + +- Make the smallest complete change that satisfies the approved requirement. Reuse existing modules and dependencies; avoid unrelated cleanup, speculative abstractions, and partially wired features. +- Treat public Python imports/signatures, CLI/configuration behavior, generated projects, Studio API types, and runtime/event contracts as consumer-facing boundaries. Breaking changes require explicit approval and a plan for affected callers and data; do not copy another project's blanket no-compatibility policy. +- Cover asynchronous cancellation, timeouts, late responses/events, terminal states, and resource cleanup where affected. Do not turn errors into silent success or empty results. +- Keep real credentials, signed URLs, personal data, and unredacted production logs out of source, design documents, reports, test fixtures, and generated artifacts. +- Isolate tests from real user state and external services by default. Real cloud/provider operations need explicit authorization and an isolated smoke/E2E procedure; report simulated and live verification separately. + +## Verification gates + +Use the repository-local Python environment and current `pyproject.toml`, `pytest.ini`, package scripts, and CI workflows as command references. Do not invent `make` targets, copy another repository's coverage thresholds, or treat an unavailable check as passing. + +| Change area | Required verification | +| --- | --- | +| Python SDK, CLI, and backend | Targeted tests via `uv run --extra dev pytest `; broader regression tests when shared contracts or dependencies are affected. | +| Default parallel Python regression | `uv run --extra dev pytest -n 2 -m "not codex_smoke and not piagent_smoke"`; adjust worker count to available resources and record the selection. Explicit smoke runs are separate. | +| Codex runtime smoke | When affected and the environment is prepared: `CODEX_RUN_SMOKE=1 uv run --extra dev pytest -m codex_smoke -p no:xdist -rs`. This test starts real processes and binds ports; never run it under parallel pytest. Verify it actually ran rather than skipped. | +| Studio frontend | `npm --prefix frontend test` and `npm --prefix frontend run build`, plus real browser checks of the normal flow and affected loading, empty, error, cancellation, retry, keyboard/IME, and narrow-window cases. Follow `frontend/SPEC.md` for additional checks and release asset requirements. | +| Studio backend or generated Python | Targeted Python tests plus Ruff and Pyright for changed Python files, as required by `frontend/SPEC.md`; verify generated code, dependencies, configuration, and deployment payloads together. | +| Harness/sidecar contracts | The affected checks and existing coverage thresholds in `.github/workflows/harness-sidecar-release-gate.yaml`, including `npm --prefix frontend run test:harness-sidecar-coverage` when frontend sidecar contracts change. | +| PRD/spec and guideline-only changes | Check paired-language completeness, contract/identifier consistency, relative links, referenced paths/commands, and diff whitespace. No runtime test result may be claimed from documentation checks. | +| User documentation site | The affected Markdown/MDX checks and build/type checks defined in `docs/package.json`; do not apply site-specific tooling to unrelated plain Markdown without checking its scope. | + +- Before committing, run `uv run --extra dev pre-commit run --all-files` and unit tests after synchronizing the branch as described below. Pre-commit includes Ruff and secret scanning; it does not replace browser, integration, or runtime verification. +- Prefer targeted checks during iteration. Expand to affected integration/E2E and regression gates for shared or high-risk changes and final delivery; explain any omitted required check. +- Record check outcomes as `pass`, `fail`, `blocked`, `not_run`, or `not_applicable`, with reasons for the last three. Include the tested revision/diff scope and execution date. Skipped tests do not prove the relevant behavior. +- Keep change-specific review and verification summaries in the bilingual PRD by default. Separate evidence artifacts are optional when useful; OpenSpec and a multi-report hierarchy are not prerequisites. + +## Git and execution safety + +- Do not use `codex/` as a branch name prefix. Use semantic prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`, with a name reflecting the requirement. +- After creating or switching to a new branch, run `git pull` before development. If no upstream exists or synchronization fails, resolve the target explicitly rather than guessing or bypassing the requirement. +- Before committing, fetch the latest remote code, rebase the branch onto the intended remote base, then run pre-commit and unit tests. Preserve unrelated user changes and stop if safe synchronization requires a user decision. +- Commit only changes related to the current feature or fix. Commit, push, PR creation, publication, and deployment require user authorization; approval of a design does not authorize them. +- Never commit real security-sensitive information or private data to GitHub. This includes API keys, access keys, secret keys, bearer/session/refresh tokens, cookies, signed URLs, private certificates, production logs with credentials, personal data dumps, and cloud account secrets. Use placeholders, environment variables, secret references, or documented local-only setup instead. +- The repository pre-commit configuration must reject hardcoded API key and token material before a commit is created. If secret scanning reports a finding, stop and remove or redact the value rather than bypassing the hook. +- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall. Report blocked checks honestly and do not alter machine-global configuration or bypass gates to hide the issue. diff --git a/contracts/mpa-p0/benchmark-report.schema.json b/contracts/mpa-p0/benchmark-report.schema.json new file mode 100644 index 000000000..eef25507c --- /dev/null +++ b/contracts/mpa-p0/benchmark-report.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "MPA P0 benchmark report", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "revision", "endpoint", "warmupCount", + "requestCount", "concurrency", "p50Milliseconds", + "p95Milliseconds", "maxMilliseconds", "errorRate" + ], + "properties": { + "schemaVersion": {"const": 1}, + "revision": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "endpoint": {"type": "string", "minLength": 1}, + "warmupCount": {"const": 5}, + "requestCount": {"const": 100}, + "concurrency": {"const": 10}, + "p50Milliseconds": {"type": "number", "minimum": 0}, + "p95Milliseconds": {"type": "number", "minimum": 0}, + "maxMilliseconds": {"type": "number", "minimum": 0}, + "errorRate": {"type": "number", "minimum": 0, "maximum": 1} + } +} diff --git a/contracts/mpa-p0/compatibility-matrix.json b/contracts/mpa-p0/compatibility-matrix.json new file mode 100644 index 000000000..76d5b012b --- /dev/null +++ b/contracts/mpa-p0/compatibility-matrix.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "rules": [ + { + "id": "p0-codex-rest-v1", + "mpaProfileSchemaVersion": 1, + "agentkitSdkVersion": "0.8.5", + "runtimeExecutionCapability": "urn:veadk:mpa:execution:v1", + "sessionExecutionConfigSchemaVersion": 1, + "workerProtocol": "codex", + "workerRequiredEndpoints": [ + "/health", + "/ready", + "/v1/sessions", + "/v1/sessions/{session_id}/turns", + "/v1/sessions/{session_id}/events" + ] + } + ] +} diff --git a/examples/piagent_with_mcp/README.md b/examples/piagent_with_mcp/README.md index 1491ced62..2ef36b4c2 100644 --- a/examples/piagent_with_mcp/README.md +++ b/examples/piagent_with_mcp/README.md @@ -34,7 +34,7 @@ piagent_with_mcp/ ├── app.py # deployable FastAPI app on :8000 ├── Dockerfile # AgentKit cloud build image ├── requirements.txt -├── piagent-mcp-agentkit.yaml # veadk agentkit launch config +├── piagent-mcp-agentkit.yaml # local-only veadk agentkit launch config ├── vendor/ │ └── veadk_python-*.whl # local VeADK build installed by Dockerfile └── agents/ @@ -124,10 +124,20 @@ Then launch from this example directory: ```bash cd examples/piagent_with_mcp +veadk agentkit config \ + --agent_name piagent_with_mcp --entry_point app.py \ + --language Python --language_version 3.12 \ + --launch_type cloud --region cn-beijing \ + --tos_bucket Auto \ + --runtime_name piagent_with_mcp --runtime_apikey_name Auto veadk agentkit launch --config-file piagent-mcp-agentkit.yaml --platform linux/amd64 veadk agentkit status --config-file piagent-mcp-agentkit.yaml ``` +Move the generated `agentkit.yaml` to `piagent-mcp-agentkit.yaml` if you want +to follow the commands below. Keep that file local: it can contain runtime IDs, +account-specific endpoints, and API keys after launch. + Invoke after the runtime is ready: ```bash diff --git a/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml b/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml index 53532ac88..0912795bb 100644 --- a/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml +++ b/examples/piagent_with_mcp/piagent-mcp-agentkit.yaml @@ -17,18 +17,18 @@ launch_types: cr_namespace_name: agentkit cr_repo_name: piagent-with-mcp cr_auto_create_instance_type: Micro - cr_image_full_url: agentkit-platform-2107625663-cn-beijing.cr.volces.com/agentkit/piagent-with-mcp:20260715155044 + cr_image_full_url: //piagent-with-mcp: build_timeout: 3600 cp_workspace_name: agentkit-cli-workspace cp_pipeline_name: piagent-with-mcp - cp_pipeline_id: f472fcdc6f4848fe8889c0855caaa2d0 - runtime_id: r-yeqksmpybko2eybtg5z8 + cp_pipeline_id: + runtime_id: runtime_name: piagent-with-mcp - runtime_role_name: AgentKit_Runtime_Default_ServiceRole_j1gzh8c + runtime_role_name: runtime_auth_type: key_auth - runtime_apikey_name: API-KEY-da6ikern - runtime_apikey: BNwxzuKr9hoKhJwJgm2laluVs_yN_BIfzQKN + runtime_apikey_name: + runtime_apikey: runtime_jwt_allowed_clients: [] - runtime_endpoint: https://scd6b76f5sagn55rj2dh1.apigateway-cn-beijing.volceapi.com + runtime_endpoint: docker_build: platform: linux/amd64 diff --git a/frontend/README.md b/frontend/README.md index bbcd90d68..2196362ee 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,6 +5,15 @@ server that `veadk frontend` launches — no separate backend. ## Release notifications +The MPA creation wizard supports clicking step headings and returning to earlier +settings after restoring a submitted draft. Submitted settings remain read-only; +retry retains the original identity. Any submitted draft offers New Agent to +open a fresh, editable request. It replaces the browser recovery draft without +cancelling the old task; active-task admission limits still apply on submission. + +恢复已提交草稿时配置保持只读,重试沿用原身份。“新建另一个智能体”会生成新请求, +开放编辑并替换浏览器恢复草稿,不取消旧任务;提交仍受服务端活动任务限制。 + The release workflow sends one Feishu card after both cloud providers finish publishing. A separate VeFaaS Webhook discovers the app bot’s group memberships and persists delivery results to avoid duplicate notifications on retries. @@ -12,6 +21,23 @@ See [deployment and operation](service/studio_release_notifier/README.md). ## Features +Runtime connections discovered as `a2a-default` keep the A2A session protocol, +including when the agent is categorized as MPA. Session creation, history and +chat use the same bridge and do not query native MPA execution configuration. +Native MPA apps continue to use their Profile and session APIs; authentication +errors never cause an automatic protocol switch. + +For MPA agents connected through A2A, Studio uses the sandbox's completed answer +once and omits only exact complete outer-model answer copies for the same task. +Distinct supplementary text, reasoning, and partial output remain available. Sandbox tool activity +and token usage remain available. This normalization runs in the Studio backend; +restart the local Studio process after updating it. Ordinary ADK chat is unaffected. + +MPA A2A transcripts also reconcile repeated consolidated outer reasoning +snapshots. Empty-response notices are evaluated across the whole user request, +so an intermediate reasoning fragment does not report an empty reply when tools +or an answer follow. General agents retain their existing per-turn behavior. + - **Ark API Keys**: Each page load reads the current API Key list directly from the cloud provider. Enabled, disabled, all-permission and custom-permission keys are listed with their status and permission scope beneath the name. @@ -26,7 +52,6 @@ See [deployment and operation](service/studio_release_notifier/README.md). Overlapping identical requests share one in-flight cloud query, including failures; later API Key list reads still fetch the latest cloud data. This behavior applies to both Volcengine and BytePlus - - **Agent publication review**: Developers deploy privately and apply from an Agent card. The review center's Agent tab lets administrators inspect the submitted Runtime metadata, approve with an optional comment, or return with @@ -1093,6 +1118,25 @@ When `--user-pool-id` and `--allowed-client-id` are omitted, deployment creates or reuses them in the selected `--region` and prints the resolved IDs. Pass both options to keep using existing Identity resources. +The cloud deploy and update commands register both `/oauth2/callback` for Studio +SSO and `/oauth/callback` for MPA user authorization on that same client. An MPA +Runtime can therefore reuse the deployed Studio user pool and client and set its +identity callback URL to `https:///oauth/callback`. The callback +validates the MPA relay state without requiring a Studio browser session, +redirects the browser through the configured UserPool, then locates exactly one +key-auth Runtime by `MPA_AGENT_ID` and calls its `/identity/oauth/callback` +endpoint from the server. The browser never receives the Runtime endpoint or +API key. + +For Studio web chat with an MPA Runtime, the Studio server refreshes the signed-in +user's UserPool session before each run and prewarms the Runtime's +`/identity/sessions/put` using the server-held Runtime key. The ID token stays +server-side and out of the browser session cookie. This requires Studio and MPA +to use the same UserPool client, a refresh-backed Studio login, and an MPA +Runtime that recognizes Studio's `x-user-id` as the user identity. If the +handoff fails, chat continues and MPA can ask the user to log in normally. +Feishu chat keeps its separate manual login flow. + After automatic provisioning, the success summary lists every Sandbox type and Tool ID, the private Studio TOS address, and the resolved Identity user pool and client IDs. It also links to the matching Volcengine or BytePlus Identity @@ -1537,6 +1581,92 @@ DatePicker 在基础组件中展示日期与日期时间选择;IndexLayout 在 `npm run dev:components` 启动独立组件库,在布局分组打开 App layout 可查看完整侧栏 支持 240px / 56px 展开折叠、会话菜单、账号区域与默认深色的主题切换 全屏入口为 `/components-preview/?fullscreen=app-layout#app-layout`,详见 [组件预览说明](src/components-preview/README.md) +### MPA 消息渠道 + +在“自动化 → 消息渠道”打开“MPA智能体消息渠道”,与飞书机器人创建、网站集成并列。选择已有 MPA 智能体后配置飞书、企业微信、钉钉;原智能体详情不再显示消息渠道栏目。列表按当前账号授权范围加载,选项展示名称、描述、“创建者 | 相对创建时间”,以及地域和 Runtime ID,支持按名称、Runtime ID、地域搜索已加载结果,并可加载更多;切换智能体会清理临时二维码和凭据,已有绑定继续保留。三个渠道均提供“极速配置”和“手动配置”,默认极速配置,切换方式后仅显示对应内容。极速配置沿用飞书、钉钉扫码绑定和企业微信官方 SDK 弹窗授权;手动配置分别填写飞书 App ID / App Secret、钉钉 Client ID / Client Secret、企业微信 Bot ID / Secret;仅飞书提供允许群列表,并独立展示消息网关、入站路由和回复投递状态。钉钉与企业微信无需配置本地群白名单。所有渠道均可解绑,旧 Runtime 必须升级后才能使用新增绑定接口。需要 Studio 管理员身份、Runtime 的 `CHANNEL_ADMIN_AUTH_MODE=runtime_key`,以及通过部署配置注入并在更新时保留的 `CHANNEL_STATE_ENCRYPTION_KEY`(Fernet key)。渠道数据库必须独立于其他 MPA 实例。飞书和钉钉的扫码凭据由服务端获取;企微 SDK 授权结果仅在浏览器内存中短暂存在,立即提交 Runtime 保存并完成注册,取消、切页和五分钟超时会清理弹窗。配置完成不等于收发验证通过;部署后请单独验收私聊和授权群 @ 对话。详见 [接口契约](../specs/mpa-channels/README.zh.md)。 + +已绑定飞书、钉钉或企业微信时,可点击“重新生成二维码”展开“消息渠道扫码配对”。生成二维码不会先解绑当前机器人;飞书扫码并绑定成功后替换原机器人,可在授权流程选择绑定存量机器人或新建机器人。 + +企业微信的“重新生成二维码”会重新打开企微扫码授权窗口,成功绑定后更新连接,取消授权或失败保留原机器人。 + +配对二维码仅在当前渠道面板临时展示,不显示“等待扫码”或“打开授权页面”。切换渠道、离开页面、浏览器刷新或面板刷新后不恢复旧二维码,需要重新点击生成;已绑定机器人配置保留。 + +三个渠道的解除连接操作统一为“解除绑定”;已绑定企业微信的“重新生成二维码”与“解除绑定”横向排列,与飞书和钉钉一致。 + +飞书群聊权限列表不再提供“编辑”;每行右侧的 × 按钮用于移除该群权限。 + +配置方式不跨页面或 Runtime 保存。切换方式会清空未提交凭据、隐藏二维码并停止前端轮询或企微授权等待;注册提交进行中禁止切换。已绑定机器人也可以通过手动配置替换,失败保留现有绑定。飞书与钉钉手动配置须由 Runtime 的 `credentialBindingChannels` 声明支持;旧镜像显示升级提示。 + +### MPA Runtime scheduled tasks + +Select a connected cloud Runtime and open the Runtime scheduled tasks tab. The list/calendar supports search, status filtering, pagination, creation, editing, copying, deletion, enable/disable, manual execution, and execution history. Studio retains gateway authentication and Runtime authorization, forwards the authenticated Studio user as `x-user-id`, and does not require JWT input or TOP credential exchange. With MPA JWT disabled, task ownership remains scoped to that user. The Runtime resolves the default executing Agent; no Agent ID input is required. Errors are not rendered as empty results. Complex Cron expressions show the server's next execution in the calendar. + +Run `npm run test:mpa-cron-coverage` and `python -m pytest tests/frontend/server/test_mpa_cron.py` for focused regression checks. + +### A2A 长耗时请求 + +连接 A2A Runtime 后,对话会显示等待响应、排队或执行中的状态。收到有效状态后会继续等待最终回复,不因任务耗时超过 30 秒而自动中断。等待响应不代表 Runtime 已接受任务;错误或连接中断也不代表后台任务已取消,请先确认任务状态再重试创建等操作。 + +### Sandbox file downloads + +Assistant Markdown links under `/data/output/` or `/data/workspace/` appear as download buttons in conversation history and streaming messages. Studio uses the current Runtime and session through its authenticated proxy; files remain available only while that Sandbox and file exist. Legacy `` and `` payloads are hidden in conversation rendering. A failed download can be retried by clicking the button again. Run `npm run test:sandbox-download-coverage` for the focused regression suite. + + +### MPA conversation information rail / MPA 会话信息侧栏 + +Only MPA Runtimes show the right conversation rail. AGENTS.md displays the actual Runtime document through the authenticated Studio metadata endpoint. Upgrade the MPA Runtime image to expose `/api/v1/studio/agent-info`; Studio alone cannot add the endpoint to deployed agents. Runtime credentials stay server-side. + +Skills show only the bound Skill Spaces, with pagination. Temporary mounting has been removed, and old temporary selections are excluded from MPA messages. Click a skill name to view its latest SKILL.md. Authorized owners/admins can save a new version to that space; shared/review spaces remain read-only. Saving preserves other files, rejects stale versions and changes to the frontmatter name, and can affect other agents bound to the same space. Running sessions may need a cache refresh or a new session. Use **Refresh information** to reload the rail; environment controls remain available. + +右侧会话信息栏仅对 MPA Runtime 展示。AGENTS.md 通过受认证的 Studio 元信息接口显示真实正文。需要更新 MPA Runtime 镜像以提供 `/api/v1/studio/agent-info`;单独更新 Studio 无法给已部署智能体增加此接口。Runtime 凭据仅保留在服务端。 + +技能仅展示绑定空间的内容并支持分页。已取消临时挂载,旧的临时技能选择不会再发送给 MPA。点击技能名称查看最新 SKILL.md;有权限的所有者/管理员可保存新版本到该空间,共享/审核空间保持只读。保存保留其他文件,拒绝过期版本及 frontmatter 名称变化,可能影响绑定同一空间的其他智能体。运行中的会话可能需要等待缓存刷新或新建会话后生效。点击“刷新信息”重新加载侧栏;环境操作保持可用。 + +### Production component themes + +The production entry loads both component token stylesheets before rendering. When the host does not set `data-theme` on ``, Studio uses `light` to match its existing page; an explicit `light` or `dark` value is preserved. This keeps portalled dialogs, their text, inputs and buttons on the same palette. Component-preview defaults are unchanged. + +生产入口在渲染前加载两份组件主题样式。宿主未在 `` 上设置 `data-theme` 时沿用浅色页面;显式浅色或深色设置保持不变,确保弹窗、文字、输入框和按钮配色一致。组件预览的默认主题不变。 + +### Named MPA creation / MPA 名称创建 + +Studio resolves shared VPC/subnet and APIG/IM Gateway from the account/region +records in `mpa_admin_workspace/mpa_admin_db`. It creates and persists missing +resources, then reuses them for later agents. The built-in profile does not pin +existing resource IDs. Existing records are preserved; this does not migrate +agents or bypass resource quotas. + +Studio 从 `mpa_admin_workspace/mpa_admin_db` 的账号/地域记录解析共享 VPC/子网及 +APIG/IM Gateway;缺失时创建并保存,后续智能体复用。内置配置不固定已有资源 ID。 +已有记录保持不变;本次不迁移智能体或绕过资源配额。 + +With `managed.postgres` configured, the PG step uses a shared business Workspace with a separate database per MPA. Shared account/region resource records live in `mpa_admin_workspace/mpa_admin_db`. With `managed.postgres.mode: auto`, deployment STS creates/reuses both Workspaces and the PG step needs no connection input. Manual profiles remain supported. See the [setup and registry migration instructions](../veadk/integrations/mpa/managed/README.md). + +配置 `managed.postgres` 后,PG 步骤使用共享业务 Workspace,每个 MPA 保留独立业务库。账号/地域共享资源记录存入 `mpa_admin_workspace/mpa_admin_db`。设置 `managed.postgres.mode: auto` 后,由部署 STS 创建/复用两个 Workspace,PG 步骤无需填写连接信息;仍兼容手动配置。参见[配置和注册库迁移说明](../veadk/integrations/mpa/managed/README.zh.md)。 + +The MPA creation dialog asks for a Runtime name (4–64 ASCII letters, digits, underscores or hyphens) and description. The server generates the stable Agent ID; neither ID nor image inputs are shown. Fresh requests use the built-in `mpa/mpa_agent:latest` and `mpa/mpa_codex_worker:latest` images. Submitted legacy drafts retain their original request and images for retry. Existing Runtimes are unchanged. See [managed creation](../veadk/integrations/mpa/managed/README.md). + +The dialog now uses three steps: basics (required Runtime name and description), automatic PostgreSQL preparation, and optional OpenViking HTTPS URL/resource ID/API Key. It links to the relevant Volcengine console pages. The server obtains PostgreSQL Workspace connections through deployment credentials. OpenViking is enabled only when the HTTPS URL, resource ID and masked API Key are all entered; leaving all three blank omits its four Runtime variables, including any values inherited from a template or reference Runtime. The key is never saved in browser drafts or task SQLite. Only the final step submits; nonsecret draft choices and request identity survive reopening in the same browser session. See the [creation contract](../specs/studio-mpa-creation/README.md). + +创建弹窗现分为基础信息(必填 Runtime 名称和描述)、PostgreSQL 自动准备、可选 OpenViking HTTPS 地址/资源 ID/API Key 三步,并提供对应的火山引擎控制台入口。服务端通过部署凭据获取 PostgreSQL Workspace 连接。仅在 HTTPS 地址、资源 ID 和遮罩的 API Key 三项均填写时启用 OpenViking;三项全空时,新 Runtime 不注入其四个变量,也不继承模板或参考 Runtime 中的旧值。密钥不保存到浏览器草稿或任务 SQLite。仅最后一步提交,非密钥草稿与请求身份在同一浏览器会话中可恢复。参见[创建契约](../specs/studio-mpa-creation/README.zh.md)。 + +After a confirmed failure or cancellation, the dialog retains the old task for same-ID retry. “Create another agent” generates a new request UUID and blank name; the server derives the new agent ID on submission and returns to the first step. It replaces only the browser draft, not any cloud resources from the previous attempt. Running or uncertain submissions cannot switch identities. + +失败或取消后,创建弹窗保留原任务供同 ID 重试;点击“新建另一个智能体”会生成新的请求 UUID 和空白名称并返回第一步,服务端在提交时派生新智能体 ID。该操作只替换浏览器草稿,不会删除上次创建的云资源。运行中或提交结果不明时不能切换到新身份。 + +MPA 创建弹窗填写 Runtime 名称(4–64 个 ASCII 字母、数字、下划线或连字符)和描述。服务端生成稳定的智能体 ID,不再显示 ID 和镜像输入框。新请求使用内置 `mpa/mpa_agent:latest` 和 `mpa/mpa_codex_worker:latest` 镜像。已提交旧草稿保留原请求和镜像用于重试。现有 Runtime 不变。参见[托管创建说明](../veadk/integrations/mpa/managed/README.zh.md)。 + +MPA A2A responses now group adjacent assistant fragments into one reply with a single action row. Copy/share include the grouped response; feedback remains attached to the final answer event, and the trace entry retains the session timeline through the latest fragment. Reported request tokens are deduplicated by usage source/event, including trailing usage updates. General-agent rendering is unchanged. + +MPA grouped replies hide exact answer mirrors only in their derived view: an extended fragment reappears intact, including its original prefix. General Codex tool activity preserves commentary and subsequent model text under its original behavior. + +### MPA A2A shared gateway compatibility + +MPA conversations (native or A2A) hide the separate “会话产物” entry because it does not read the Worker `/data/output` mount. General Runtime sessions retain the entry. Existing MPA Markdown file-download links are unchanged. + +Fresh flat MPA creation defaults to `ENABLE_A2A=true` and `DISABLE_JWT_AUTH=true`; explicit `false` overrides and referenced Runtime/template environments are preserved. The inner legacy REST JWT gate is bypassed, including its admin checks and header-based identity paths; trusted callers must control identity headers. Outer gateway key-auth is unchanged. Studio now prefers a valid Agent Card for tagged MPA Runtimes, selecting `a2a-default` even when the Runtime also advertises ADK. General Runtimes and MPA without a usable card keep existing discovery behavior. This avoids native Profile/session preflight on the A2A path; it does not enable `MPA_AGENTKIT_MODE`, change independent management-endpoint authentication or migrate old sessions. If a tagged MPA Runtime's agent card omits the shared gateway `/runtime/` prefix from its same-origin `/a2a/jsonrpc` URL, the Studio backend still restores the prefix from the control-plane endpoint for chat and history requests. General-agent URLs are unchanged. Existing Runtimes need an explicit configuration release for environment changes; reconnect to refresh discovery. Restart Studio after this backend update; no frontend rebuild is required. + +新建 MPA 沙箱模板按智能体 ID 命名,并将 `-` 替换为 `_`(例如 `mi-example` → `mi_example`);已有模板保持原绑定,未完成的旧创建任务继续按原名称重试。 ### 智能构建首页与任务找回 @@ -1618,3 +1748,79 @@ it. Deleting a default set stops feedback writes to that set and prevents backgr List filtering and counts currently scan only the selected Runtime's objects, with bounded parallel reads. This is intended for the initial dataset sizes; large collections will need a separate rebuildable query index. +### MPA 消息渠道 + +在“自动化 → 消息渠道”打开“MPA智能体消息渠道”,与飞书机器人创建、网站集成并列。选择已有 MPA 智能体后配置飞书、企业微信、钉钉;原智能体详情不再显示消息渠道栏目。列表按当前账号授权范围加载,选项展示名称、描述、“创建者 | 相对创建时间”,以及地域和 Runtime ID,支持按名称、Runtime ID、地域搜索已加载结果,并可加载更多;切换智能体会清理临时二维码和凭据,已有绑定继续保留。三个渠道均提供“极速配置”和“手动配置”,默认极速配置,切换方式后仅显示对应内容。极速配置沿用飞书、钉钉扫码绑定和企业微信官方 SDK 弹窗授权;手动配置分别填写飞书 App ID / App Secret、钉钉 Client ID / Client Secret、企业微信 Bot ID / Secret;仅飞书提供允许群列表,并独立展示消息网关、入站路由和回复投递状态。钉钉与企业微信无需配置本地群白名单。所有渠道均可解绑,旧 Runtime 必须升级后才能使用新增绑定接口。需要 Studio 管理员身份、Runtime 的 `CHANNEL_ADMIN_AUTH_MODE=runtime_key`,以及通过部署配置注入并在更新时保留的 `CHANNEL_STATE_ENCRYPTION_KEY`(Fernet key)。渠道数据库必须独立于其他 MPA 实例。飞书和钉钉的扫码凭据由服务端获取;企微 SDK 授权结果仅在浏览器内存中短暂存在,立即提交 Runtime 保存并完成注册,取消、切页和五分钟超时会清理弹窗。配置完成不等于收发验证通过;部署后请单独验收私聊和授权群 @ 对话。详见 [接口契约](../specs/mpa-channels/README.zh.md)。 + +已绑定飞书、钉钉或企业微信时,可点击“重新生成二维码”展开“消息渠道扫码配对”。生成二维码不会先解绑当前机器人;飞书扫码并绑定成功后替换原机器人,可在授权流程选择绑定存量机器人或新建机器人。 + +企业微信的“重新生成二维码”会重新打开企微扫码授权窗口,成功绑定后更新连接,取消授权或失败保留原机器人。 + +配对二维码仅在当前渠道面板临时展示,不显示“等待扫码”或“打开授权页面”。切换渠道、离开页面、浏览器刷新或面板刷新后不恢复旧二维码,需要重新点击生成;已绑定机器人配置保留。 + +三个渠道的解除连接操作统一为“解除绑定”;已绑定企业微信的“重新生成二维码”与“解除绑定”横向排列,与飞书和钉钉一致。 + +飞书群聊权限列表不再提供“编辑”;每行右侧的 × 按钮用于移除该群权限。 + +配置方式不跨页面或 Runtime 保存。切换方式会清空未提交凭据、隐藏二维码并停止前端轮询或企微授权等待;注册提交进行中禁止切换。已绑定机器人也可以通过手动配置替换,失败保留现有绑定。飞书与钉钉手动配置须由 Runtime 的 `credentialBindingChannels` 声明支持;旧镜像显示升级提示。 + +### MPA Runtime scheduled tasks + +Select a connected cloud Runtime and open the Runtime scheduled tasks tab. The list/calendar supports search, status filtering, pagination, creation, editing, copying, deletion, enable/disable, manual execution, and execution history. Studio retains gateway authentication and Runtime authorization, forwards the authenticated Studio user as `x-user-id`, and does not require JWT input or TOP credential exchange. With MPA JWT disabled, task ownership remains scoped to that user. The Runtime resolves the default executing Agent; no Agent ID input is required. Errors are not rendered as empty results. Complex Cron expressions show the server's next execution in the calendar. + +Run `npm run test:mpa-cron-coverage` and `python -m pytest tests/frontend/server/test_mpa_cron.py` for focused regression checks. + +### A2A 长耗时请求 + +连接 A2A Runtime 后,对话会显示等待响应、排队或执行中的状态。收到有效状态后会继续等待最终回复,不因任务耗时超过 30 秒而自动中断。等待响应不代表 Runtime 已接受任务;错误或连接中断也不代表后台任务已取消,请先确认任务状态再重试创建等操作。 + +### Sandbox file downloads + +Assistant Markdown links under `/data/output/` or `/data/workspace/` appear as download buttons in conversation history and streaming messages. Studio uses the current Runtime and session through its authenticated proxy; files remain available only while that Sandbox and file exist. Legacy `` and `` payloads are hidden in conversation rendering. A failed download can be retried by clicking the button again. Run `npm run test:sandbox-download-coverage` for the focused regression suite. + + +### MPA conversation information rail / MPA 会话信息侧栏 + +Only MPA Runtimes show the right conversation rail. AGENTS.md displays the actual Runtime document through the authenticated Studio metadata endpoint. Upgrade the MPA Runtime image to expose `/api/v1/studio/agent-info`; Studio alone cannot add the endpoint to deployed agents. Runtime credentials stay server-side. + +Skills show only the bound Skill Spaces, with pagination. Temporary mounting has been removed, and old temporary selections are excluded from MPA messages. Click a skill name to view its latest SKILL.md. Authorized owners/admins can save a new version to that space; shared/review spaces remain read-only. Saving preserves other files, rejects stale versions and changes to the frontmatter name, and can affect other agents bound to the same space. Running sessions may need a cache refresh or a new session. Use **Refresh information** to reload the rail; environment controls remain available. + +右侧会话信息栏仅对 MPA Runtime 展示。AGENTS.md 通过受认证的 Studio 元信息接口显示真实正文。需要更新 MPA Runtime 镜像以提供 `/api/v1/studio/agent-info`;单独更新 Studio 无法给已部署智能体增加此接口。Runtime 凭据仅保留在服务端。 + +技能仅展示绑定空间的内容并支持分页。已取消临时挂载,旧的临时技能选择不会再发送给 MPA。点击技能名称查看最新 SKILL.md;有权限的所有者/管理员可保存新版本到该空间,共享/审核空间保持只读。保存保留其他文件,拒绝过期版本及 frontmatter 名称变化,可能影响绑定同一空间的其他智能体。运行中的会话可能需要等待缓存刷新或新建会话后生效。点击“刷新信息”重新加载侧栏;环境操作保持可用。 + +### Production component themes + +The production entry loads both component token stylesheets before rendering. When the host does not set `data-theme` on ``, Studio uses `light` to match its existing page; an explicit `light` or `dark` value is preserved. This keeps portalled dialogs, their text, inputs and buttons on the same palette. Component-preview defaults are unchanged. + +生产入口在渲染前加载两份组件主题样式。宿主未在 `` 上设置 `data-theme` 时沿用浅色页面;显式浅色或深色设置保持不变,确保弹窗、文字、输入框和按钮配色一致。组件预览的默认主题不变。 + +### Named MPA creation / MPA 名称创建 + +With `managed.postgres` configured, the PG step uses a shared business Workspace with a separate database per MPA. Shared account/region resource records live in `mpa_admin_workspace/mpa_admin_db`. With `managed.postgres.mode: auto`, deployment STS creates/reuses both Workspaces and the PG step needs no connection input. Manual profiles remain supported. See the [setup and registry migration instructions](../veadk/integrations/mpa/managed/README.md). + +配置 `managed.postgres` 后,PG 步骤使用共享业务 Workspace,每个 MPA 保留独立业务库。账号/地域共享资源记录存入 `mpa_admin_workspace/mpa_admin_db`。设置 `managed.postgres.mode: auto` 后,由部署 STS 创建/复用两个 Workspace,PG 步骤无需填写连接信息;仍兼容手动配置。参见[配置和注册库迁移说明](../veadk/integrations/mpa/managed/README.zh.md)。 + +The MPA creation dialog asks for a Runtime name (4–64 ASCII letters, digits, underscores or hyphens) and description. The server generates the stable Agent ID; neither ID nor image inputs are shown. Fresh requests use the built-in `mpa/mpa_agent:latest` and `mpa/mpa_codex_worker:latest` images. Submitted legacy drafts retain their original request and images for retry. Existing Runtimes are unchanged. See [managed creation](../veadk/integrations/mpa/managed/README.md). + +The dialog now uses three steps: basics (required Runtime name and description), automatic PostgreSQL preparation, and optional OpenViking HTTPS URL/resource ID/API Key. It links to the relevant Volcengine console pages. The server obtains PostgreSQL Workspace connections through deployment credentials. OpenViking is enabled only when the HTTPS URL, resource ID and masked API Key are all entered; leaving all three blank omits its four Runtime variables, including any values inherited from a template or reference Runtime. The key is never saved in browser drafts or task SQLite. Only the final step submits; nonsecret draft choices and request identity survive reopening in the same browser session. See the [creation contract](../specs/studio-mpa-creation/README.md). + +创建弹窗现分为基础信息(必填 Runtime 名称和描述)、PostgreSQL 自动准备、可选 OpenViking HTTPS 地址/资源 ID/API Key 三步,并提供对应的火山引擎控制台入口。服务端通过部署凭据获取 PostgreSQL Workspace 连接。仅在 HTTPS 地址、资源 ID 和遮罩的 API Key 三项均填写时启用 OpenViking;三项全空时,新 Runtime 不注入其四个变量,也不继承模板或参考 Runtime 中的旧值。密钥不保存到浏览器草稿或任务 SQLite。仅最后一步提交,非密钥草稿与请求身份在同一浏览器会话中可恢复。参见[创建契约](../specs/studio-mpa-creation/README.zh.md)。 + +After a confirmed failure or cancellation, the dialog retains the old task for same-ID retry. “Create another agent” generates a new request UUID and blank name; the server derives the new agent ID on submission and returns to the first step. It replaces only the browser draft, not any cloud resources from the previous attempt. Running or uncertain submissions cannot switch identities. + +失败或取消后,创建弹窗保留原任务供同 ID 重试;点击“新建另一个智能体”会生成新的请求 UUID 和空白名称并返回第一步,服务端在提交时派生新智能体 ID。该操作只替换浏览器草稿,不会删除上次创建的云资源。运行中或提交结果不明时不能切换到新身份。 + +MPA 创建弹窗填写 Runtime 名称(4–64 个 ASCII 字母、数字、下划线或连字符)和描述。服务端生成稳定的智能体 ID,不再显示 ID 和镜像输入框。新请求使用内置 `mpa/mpa_agent:latest` 和 `mpa/mpa_codex_worker:latest` 镜像。已提交旧草稿保留原请求和镜像用于重试。现有 Runtime 不变。参见[托管创建说明](../veadk/integrations/mpa/managed/README.zh.md)。 + +MPA A2A responses now group adjacent assistant fragments into one reply with a single action row. Copy/share include the grouped response; feedback remains attached to the final answer event, and the trace entry retains the session timeline through the latest fragment. Reported request tokens are deduplicated by usage source/event, including trailing usage updates. General-agent rendering is unchanged. + +MPA grouped replies hide exact answer mirrors only in their derived view: an extended fragment reappears intact, including its original prefix. General Codex tool activity preserves commentary and subsequent model text under its original behavior. + +### MPA A2A shared gateway compatibility + +MPA creation defaults to A2A discovery (`ENABLE_A2A=true`, `DISABLE_JWT_AUTH=false`). When a tagged MPA Runtime's agent card omits the shared gateway `/runtime/` prefix from its same-origin `/a2a/jsonrpc` URL, the Studio backend restores the prefix from the control-plane endpoint for chat and history requests. General-agent URLs are unchanged. Existing Runtimes need an explicit configuration release; reconnect to refresh discovery. Restart Studio after this backend update; no frontend rebuild is required. + +MPA name validation highlights invalid input and its message in the theme danger color, offers a valid-name placeholder, and visibly disables Next until corrected. Shared Button disabled states are dimmed consistently; loading retains its own appearance. + +MPA 名称校验使用主题错误色显示非法输入及错误提示,并提供合法名称示例;名称修正前“下一步”明显置灰。共享 Button 的禁用态统一降低透明度,加载态保留原有外观。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 39d46b9b0..35df7cda4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@mdxeditor/editor": "^4.1.0", "@openai/apps-sdk-ui": "^0.2.2", "@uiw/react-codemirror": "^4.25.11", + "@wecom/wecom-aibot-sdk": "0.1.0", "@xyflow/react": "^12.11.0", "echarts": "6.1.0", "highlight.js": "^11.11.1", @@ -5550,6 +5551,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wecom/wecom-aibot-sdk": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@wecom/wecom-aibot-sdk/-/wecom-aibot-sdk-0.1.0.tgz", + "integrity": "sha512-rqYS3F0t8+433tQGJCU2pTOBHZD28AYnCG34T7p/4TuMeGsayPTfu2Lef09PZf5ISkJEFYTy0F5GAnajTuzfGA==", + "license": "MIT" + }, "node_modules/@xyflow/react": { "version": "12.11.0", "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.11.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1f7dc74f0..42c468208 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,8 +7,11 @@ "scripts": { "dev": "vite", "build": "node scripts/build.mjs", - "test": "node --test tests/*.test.mjs", + "test": "node --test tests/*.test.mjs && npm run test:mpa-agent-info", + "test:mpa-agent-info": "vitest run tests/mpaAgentInfoRail.test.tsx tests/skillDocumentEditor.test.tsx tests/mpaCreation.test.tsx", "check:i18n": "node scripts/check-i18n.mjs", + "test:sandbox-download-coverage": "vitest run --coverage -c vitest.sandbox-download.config.ts", + "test:mpa-cron-coverage": "vitest run --coverage -c vitest.mpa-cron.config.ts", "test:webui-assets": "node scripts/verifyBuiltAssets.mjs", "test:visualizations": "node scripts/testVisualizationCompatibility.mjs", "test:harness-sidecar-coverage": "vitest run --coverage -c vitest.harness-sidecar.config.ts", @@ -31,6 +34,7 @@ "@mdxeditor/editor": "^4.1.0", "@openai/apps-sdk-ui": "^0.2.2", "@uiw/react-codemirror": "^4.25.11", + "@wecom/wecom-aibot-sdk": "0.1.0", "@xyflow/react": "^12.11.0", "echarts": "6.1.0", "highlight.js": "^11.11.1", diff --git a/frontend/scripts/verifyBuiltAssets.mjs b/frontend/scripts/verifyBuiltAssets.mjs index f277f0817..bea489736 100644 --- a/frontend/scripts/verifyBuiltAssets.mjs +++ b/frontend/scripts/verifyBuiltAssets.mjs @@ -17,6 +17,7 @@ const expectedDirectories = [ "assets/visualizations/echarts", "assets/visualizations/mermaid", ]; +const unresolvedConflictMarker = /^(?:<{7,}|\|{7,}|={7,}|>{7,})(?: .*)?\r?$/m; async function filesUnder(directory) { const entries = await readdir(directory, { withFileTypes: true }); @@ -64,6 +65,10 @@ function verifyReference(fromFile, reference) { for (const relativeFile of relativeFiles) { if (!/\.(?:html|css|js)$/.test(relativeFile)) continue; const contents = await readFile(path.join(webuiRoot, relativeFile), "utf8"); + assert.ok( + !unresolvedConflictMarker.test(contents), + `${relativeFile} contains an unresolved Git conflict marker`, + ); if (relativeFile.endsWith(".js")) { for (const reference of extractJavaScriptImports(contents, relativeFile)) { verifyReference(relativeFile, reference); diff --git a/frontend/server/mpa/__init__.py b/frontend/server/mpa/__init__.py new file mode 100644 index 000000000..1f8e50116 --- /dev/null +++ b/frontend/server/mpa/__init__.py @@ -0,0 +1,74 @@ +# 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. + +"""Studio MPA management composition helpers.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from frontend.server.storage import ( + STUDIO_STORAGE_UNAVAILABLE_REASON, + StudioProvider, + StudioStorageConfig, +) +from frontend.server.storage.tos import CredentialResolver, create_tos_client_factory + +from .operations import InMemoryMpaOperationRepository, TosMpaOperationRepository +from .runtime_client import MpaRuntimeClient +from .routes import mount_mpa_profile_routes +from .service import MpaAgentOperationService + + +def create_operation_service( + *, + provider: StudioProvider = "volcengine", + resolve_credentials: CredentialResolver | None = None, + client_factory: Callable[[], Any] | None = None, + runtime_client: MpaRuntimeClient | None = None, + allow_in_memory: bool = False, +) -> MpaAgentOperationService | None: + """Compose the MPA operation service. + + Production uses TOS for durable CAS. Local Studio development can opt into a + process-local repository so profile application remains testable without TOS. + """ + storage = StudioStorageConfig.from_env(provider) + if not storage.configured: + if not allow_in_memory: + return None + return MpaAgentOperationService( + repository=InMemoryMpaOperationRepository(), + runtime_client=runtime_client or MpaRuntimeClient(), + ) + if client_factory is None: + if resolve_credentials is None: + return None + client_factory = create_tos_client_factory(storage, resolve_credentials) + return MpaAgentOperationService( + repository=TosMpaOperationRepository( + bucket=storage.bucket, + client_factory=client_factory, + ), + runtime_client=runtime_client or MpaRuntimeClient(), + ) + + +__all__ = [ + "MpaAgentOperationService", + "STUDIO_STORAGE_UNAVAILABLE_REASON", + "create_operation_service", + "mount_mpa_profile_routes", +] diff --git a/frontend/server/mpa/operations.py b/frontend/server/mpa/operations.py new file mode 100644 index 000000000..a41e7f11b --- /dev/null +++ b/frontend/server/mpa/operations.py @@ -0,0 +1,452 @@ +# Copyright (c) 2026 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. + +"""TOS-CAS persistence for Studio MPA lifecycle operations.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from collections.abc import Callable +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Generic, Literal, TypeVar +from urllib.parse import quote + +from pydantic import BaseModel, ConfigDict, Field + +_MAX_OBJECT_BYTES = 2 * 1024 * 1024 +MPA_OPERATION_ROOT_PREFIX = "veadk-studio/v4" +_Value = TypeVar("_Value", bound=BaseModel) +_TERMINAL_STATUSES = {"succeeded", "failed_terminal", "cancelled"} + + +class MpaOperationNotFound(LookupError): + """The requested MPA lifecycle operation does not exist.""" + + +class MpaOperationConflict(RuntimeError): + """The MPA operation changed concurrently or idempotency does not match.""" + + +@dataclass(frozen=True) +class Stored(Generic[_Value]): + value: _Value + etag: str + created: bool = False + + +class MpaLifecycleOperation(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + operation_id: str = Field(default="", alias="operationId") + operation_kind: Literal["create", "update"] = Field(alias="operationKind") + owner_id: str = Field(alias="ownerId", min_length=1) + target_key: str = Field(alias="targetKey", min_length=1) + idempotency_key_hash: str = Field(alias="idempotencyKeyHash", min_length=1) + request_hash: str = Field(alias="requestHash", min_length=1) + stage: str + status: Literal[ + "active", + "succeeded", + "failed_retryable", + "failed_terminal", + "cancelled", + ] + mpa_instance_id: str = Field(default="", alias="mpaInstanceId") + runtime_id: str = Field(default="", alias="runtimeId") + runtime_region: str = Field(default="", alias="runtimeRegion") + runtime_revision: str = Field(default="", alias="runtimeRevision") + profile_digest: str = Field(default="", alias="profileDigest") + profile_revision: int | None = Field(default=None, alias="profileRevision") + profile_operation_id: str = Field(default="", alias="profileOperationId") + safe_error_code: str = Field(default="", alias="safeErrorCode") + retry_count: int = Field(default=0, alias="retryCount") + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + + +def operation_id_for( + *, + owner_id: str, + operation_kind: str, + target_key: str, + idempotency_key_hash: str, +) -> str: + canonical = json.dumps( + { + "ownerId": owner_id, + "operationKind": operation_kind, + "targetKey": target_key, + "idempotencyKeyHash": idempotency_key_hash, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return f"mpaop_{hashlib.sha256(canonical).hexdigest()[:32]}" + + +def _status_code(error: Exception) -> int | None: + value = getattr(error, "status_code", None) + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None + + +class TosMpaOperationRepository: + """Persist MPA lifecycle operations with TOS conditional writes.""" + + def __init__( + self, + *, + bucket: str, + client_factory: Callable[[], Any], + root_prefix: str = MPA_OPERATION_ROOT_PREFIX, + ) -> None: + if not bucket.strip(): + raise ValueError("TOS MPA operation storage requires a bucket.") + self._bucket = bucket + self._client_factory = client_factory + self._root_prefix = root_prefix.strip("/") + + async def create_operation( + self, operation: MpaLifecycleOperation + ) -> Stored[MpaLifecycleOperation]: + return await asyncio.to_thread(self._create_operation, operation) + + async def get_operation( + self, owner_id: str, operation_id: str + ) -> Stored[MpaLifecycleOperation]: + return await asyncio.to_thread(self._get_operation, owner_id, operation_id) + + async def update_operation( + self, + operation: MpaLifecycleOperation, + etag: str, + ) -> Stored[MpaLifecycleOperation]: + return await asyncio.to_thread(self._update_operation, operation, etag) + + async def list_active_operations( + self, owner_id: str + ) -> list[MpaLifecycleOperation]: + return await asyncio.to_thread(self._list_active_operations, owner_id) + + def _create_operation( + self, operation: MpaLifecycleOperation + ) -> Stored[MpaLifecycleOperation]: + resolved = self._with_operation_id(operation) + key = self._operation_key(resolved.owner_id, resolved.operation_id) + client = self._client_factory() + try: + return self._create(client, key, resolved) + except MpaOperationConflict: + current = self._read(client, key, MpaLifecycleOperation) + if ( + current.value.idempotency_key_hash == resolved.idempotency_key_hash + and current.value.request_hash == resolved.request_hash + and current.value.operation_kind == resolved.operation_kind + and current.value.target_key == resolved.target_key + ): + return current + raise MpaOperationConflict( + "MPA operation idempotency key was reused with different input." + ) + + def _get_operation( + self, owner_id: str, operation_id: str + ) -> Stored[MpaLifecycleOperation]: + return self._read( + self._client_factory(), + self._operation_key(owner_id, operation_id), + MpaLifecycleOperation, + ) + + def _update_operation( + self, + operation: MpaLifecycleOperation, + etag: str, + ) -> Stored[MpaLifecycleOperation]: + resolved = self._with_operation_id(operation) + return self._replace( + self._client_factory(), + self._operation_key(resolved.owner_id, resolved.operation_id), + resolved, + etag, + ) + + def _list_active_operations(self, owner_id: str) -> list[MpaLifecycleOperation]: + client = self._client_factory() + operations: list[MpaLifecycleOperation] = [] + for key in self._list_keys(client, f"{self._owner_prefix(owner_id)}/"): + if not key.endswith(".json"): + continue + try: + operation = self._read(client, key, MpaLifecycleOperation).value + except MpaOperationNotFound: + continue + if operation.status not in _TERMINAL_STATUSES: + operations.append(operation) + return sorted(operations, key=lambda item: item.updated_at, reverse=True) + + def _with_operation_id( + self, operation: MpaLifecycleOperation + ) -> MpaLifecycleOperation: + if operation.operation_id: + return operation + return operation.model_copy( + update={ + "operation_id": operation_id_for( + owner_id=operation.owner_id, + operation_kind=operation.operation_kind, + target_key=operation.target_key, + idempotency_key_hash=operation.idempotency_key_hash, + ) + } + ) + + def _create(self, client: Any, key: str, value: _Value) -> Stored[_Value]: + content = self._encode(value) + try: + output = client.put_object( + bucket=self._bucket, + key=key, + content=content, + content_length=len(content), + content_type="application/json", + forbid_overwrite=True, + ) + except Exception as error: + if _status_code(error) in {409, 412}: + raise MpaOperationConflict("MPA operation already exists.") from error + raise + return Stored( + value=value, etag=self._write_etag(output, client, key), created=True + ) + + def _replace( + self, client: Any, key: str, value: _Value, etag: str + ) -> Stored[_Value]: + content = self._encode(value) + try: + output = client.put_object( + bucket=self._bucket, + key=key, + content=content, + content_length=len(content), + content_type="application/json", + if_match=etag, + ) + except Exception as error: + if _status_code(error) in {409, 412}: + raise MpaOperationConflict( + "MPA operation changed concurrently; reload and try again." + ) from error + if _status_code(error) == 404: + raise MpaOperationNotFound("MPA operation does not exist.") from error + raise + return Stored(value=value, etag=self._write_etag(output, client, key)) + + def _read(self, client: Any, key: str, model: type[_Value]) -> Stored[_Value]: + payload, etag = self._read_payload(client, key) + return Stored(value=model.model_validate(payload), etag=etag) + + def _read_payload(self, client: Any, key: str) -> tuple[dict[str, Any], str]: + try: + response = client.get_object(bucket=self._bucket, key=key) + except Exception as error: + if _status_code(error) == 404: + raise MpaOperationNotFound("MPA operation does not exist.") from error + raise + content = response.read(_MAX_OBJECT_BYTES + 1) + if not isinstance(content, bytes) or len(content) > _MAX_OBJECT_BYTES: + raise ValueError("MPA operation object is invalid or too large.") + etag = self._etag(response) + if not etag: + raise RuntimeError("TOS MPA operation object did not include an ETag.") + payload = json.loads(content) + if not isinstance(payload, dict): + raise TypeError("MPA operation object must be a JSON object.") + return payload, etag + + def _list_keys(self, client: Any, prefix: str) -> list[str]: + continuation_token = "" + keys: list[str] = [] + while True: + output = client.list_objects_type2( + bucket=self._bucket, + prefix=prefix, + continuation_token=continuation_token, + max_keys=1000, + ) + keys.extend( + str(item.key) for item in (getattr(output, "contents", None) or []) + ) + if not getattr(output, "is_truncated", False): + return keys + continuation_token = str( + getattr(output, "next_continuation_token", "") or "" + ) + if not continuation_token: + raise RuntimeError( + "TOS truncated an MPA operation listing without a continuation token." + ) + + @staticmethod + def _encode(value: BaseModel) -> bytes: + content = json.dumps( + value.model_dump(mode="json", by_alias=True), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + if len(content) > _MAX_OBJECT_BYTES: + raise ValueError("MPA operation object is too large.") + return content + + def _write_etag(self, output: Any, client: Any, key: str) -> str: + etag = self._etag(output) + if etag: + return etag + response = client.get_object(bucket=self._bucket, key=key) + etag = self._etag(response) + if not etag: + raise RuntimeError("TOS MPA operation write did not include an ETag.") + return etag + + @staticmethod + def _etag(value: Any) -> str: + direct = getattr(value, "etag", None) + if direct: + return str(direct) + return str(getattr(getattr(value, "meta", None), "etag", "") or "") + + def _operation_key(self, owner_id: str, operation_id: str) -> str: + return f"{self._owner_prefix(owner_id)}/{self._part(operation_id)}.json" + + def _owner_prefix(self, owner_id: str) -> str: + return f"{self._root_prefix}/users/{self._part(owner_id)}/mpa-agent-operations" + + @staticmethod + def _part(value: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError("TOS MPA operation key segment cannot be empty.") + return quote(text, safe="") + + +class InMemoryMpaOperationRepository: + """Process-local MPA operation store for local Studio development.""" + + def __init__(self) -> None: + self._records: dict[str, tuple[MpaLifecycleOperation, str]] = {} + self._version = 0 + self._lock = asyncio.Lock() + + async def create_operation( + self, operation: MpaLifecycleOperation + ) -> Stored[MpaLifecycleOperation]: + async with self._lock: + resolved = self._with_operation_id(operation) + current = self._records.get(resolved.operation_id) + if current is not None: + existing, etag = current + if ( + existing.idempotency_key_hash == resolved.idempotency_key_hash + and existing.request_hash == resolved.request_hash + and existing.operation_kind == resolved.operation_kind + and existing.target_key == resolved.target_key + ): + return Stored(value=deepcopy(existing), etag=etag, created=False) + raise MpaOperationConflict( + "MPA operation idempotency key was reused with different input." + ) + etag = self._next_etag() + self._records[resolved.operation_id] = (deepcopy(resolved), etag) + return Stored(value=deepcopy(resolved), etag=etag, created=True) + + async def get_operation( + self, owner_id: str, operation_id: str + ) -> Stored[MpaLifecycleOperation]: + async with self._lock: + current = self._records.get(operation_id) + if current is None or current[0].owner_id != owner_id: + raise MpaOperationNotFound("MPA operation does not exist.") + operation, etag = current + return Stored(value=deepcopy(operation), etag=etag) + + async def update_operation( + self, + operation: MpaLifecycleOperation, + etag: str, + ) -> Stored[MpaLifecycleOperation]: + async with self._lock: + resolved = self._with_operation_id(operation) + current = self._records.get(resolved.operation_id) + if current is None: + raise MpaOperationNotFound("MPA operation does not exist.") + _existing, current_etag = current + if current_etag != etag: + raise MpaOperationConflict( + "MPA operation changed concurrently; reload and try again." + ) + next_etag = self._next_etag() + self._records[resolved.operation_id] = (deepcopy(resolved), next_etag) + return Stored(value=deepcopy(resolved), etag=next_etag) + + async def list_active_operations( + self, owner_id: str + ) -> list[MpaLifecycleOperation]: + async with self._lock: + operations = [ + deepcopy(operation) + for operation, _etag in self._records.values() + if operation.owner_id == owner_id + and operation.status not in _TERMINAL_STATUSES + ] + return sorted(operations, key=lambda item: item.updated_at, reverse=True) + + def _with_operation_id( + self, operation: MpaLifecycleOperation + ) -> MpaLifecycleOperation: + if operation.operation_id: + return operation + return operation.model_copy( + update={ + "operation_id": operation_id_for( + owner_id=operation.owner_id, + operation_kind=operation.operation_kind, + target_key=operation.target_key, + idempotency_key_hash=operation.idempotency_key_hash, + ) + } + ) + + def _next_etag(self) -> str: + self._version += 1 + return f'"mem-{self._version}"' + + +__all__ = [ + "InMemoryMpaOperationRepository", + "MpaLifecycleOperation", + "MpaOperationConflict", + "MpaOperationNotFound", + "MPA_OPERATION_ROOT_PREFIX", + "Stored", + "TosMpaOperationRepository", + "operation_id_for", +] diff --git a/frontend/server/mpa/routes.py b/frontend/server/mpa/routes.py new file mode 100644 index 000000000..5c70a55b1 --- /dev/null +++ b/frontend/server/mpa/routes.py @@ -0,0 +1,1053 @@ +# 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. + +"""Studio BFF routes for MPA Profile application.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import inspect +from typing import Any + +from fastapi import Header, HTTPException, Query, Request, Response, status +from pydantic import BaseModel, ConfigDict, Field + +from frontend.server.mpa.runtime_client import ( + MpaExecutionConfigChange, + MpaProfile, + MpaProfileApplyRequest, + MpaRuntimeClient, + MpaRuntimeError, +) +from frontend.server.mpa.service import ( + MpaAgentOperationRequest, + MpaAgentOperationService, +) +from frontend.server.mpa.operations import MpaOperationConflict +from frontend.server.mpa.operations import MpaOperationNotFound +from veadk.integrations.mpa.control_plane_client import ( + MpaAgentOperationRequest as MpaAgentOperationBody, +) + +RuntimeAuthorizer = Callable[[Request, str, str], Any] +RuntimeConnectionResolver = Callable[ + [Request, str, str, Any], tuple[str, str, str, str] +] +RuntimeBindingResolver = Callable[[Request, str, str], Any] +_ACTIVE_SESSION_STATUSES = {"queued", "running", "pausing", "paused", "resuming"} + + +@dataclass(frozen=True) +class MpaRuntimeBinding: + runtime_id: str + region: str + runtime: Any + visible: dict[str, Any] + + +class MpaProfileApplyBody(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + runtime_id: str = Field(alias="runtimeId", min_length=1) + region: str = "cn-beijing" + mpa_instance_id: str = Field(default="", alias="mpaInstanceId") + source_profile_id: str = Field(alias="sourceProfileId", min_length=1) + profile: MpaProfile + create: bool = False + runtime_revision: str = Field(default="", alias="runtimeRevision") + + +class MpaRuntimeSessionBody(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + runtime_id: str = Field(alias="runtimeId", min_length=1) + region: str = "cn-beijing" + + +class MpaSessionExecutionConfigPatchBody(MpaRuntimeSessionBody): + changes: list[MpaExecutionConfigChange] = Field(default_factory=list) + + +class MpaSessionProfileUpgradeBody(MpaRuntimeSessionBody): + target_profile_revision: int = Field(alias="targetProfileRevision", ge=1) + + +def mount_mpa_profile_routes( + app: Any, + *, + runtime_client: MpaRuntimeClient, + authorize_runtime: RuntimeAuthorizer, + resolve_runtime_connection: RuntimeConnectionResolver, + operation_service: MpaAgentOperationService | None = None, + owner_resolver: Callable[[Request], str] | None = None, + resolve_runtime_bindings: RuntimeBindingResolver | None = None, +) -> None: + def owner_id(request: Request) -> str: + return owner_resolver(request) if owner_resolver is not None else "local" + + def operation_request( + *, + request: Request, + mpa_instance_id: str, + body: MpaAgentOperationBody, + operation_kind: str, + create: bool, + ) -> MpaAgentOperationRequest: + runtime = authorize_runtime(request, body.runtime_id, body.region) + endpoint, authorization, _auth_type, _network_type = resolve_runtime_connection( + request, + body.runtime_id, + body.region, + runtime, + ) + return MpaAgentOperationRequest( + ownerId=owner_id(request), + operationKind=operation_kind, + targetKey=body.target_key or mpa_instance_id, + runtimeId=body.runtime_id, + runtimeRegion=body.region, + runtimeEndpoint=endpoint, + runtimeAuthorization=authorization, + mpaInstanceId=mpa_instance_id, + sourceProfileId=body.source_profile_id, + profile=body.profile, + create=create, + runtimeRevision=body.runtime_revision, + ) + + @app.post( + "/web/mpa/agents", + status_code=status.HTTP_202_ACCEPTED, + ) + async def create_mpa_agent( + body: MpaAgentOperationBody, request: Request + ) -> dict[str, Any]: + if operation_service is None: + raise HTTPException( + status_code=503, detail="mpa_operation_service_unavailable" + ) + idempotency_key = request.headers.get("Idempotency-Key", "").strip() + if not idempotency_key: + raise HTTPException( + status_code=400, + detail={ + "code": "idempotency_key_required", + "message": "idempotency_key_required", + }, + ) + try: + operation = await operation_service.start( + operation_request( + request=request, + mpa_instance_id=body.mpa_instance_id or body.runtime_id, + body=body, + operation_kind="create", + create=True, + ), + idempotency_key=idempotency_key, + ) + except MpaOperationConflict as error: + _raise_operation_conflict(error) + return _operation_payload(operation) + + @app.patch( + "/web/mpa/agents/{mpa_instance_id}", + status_code=status.HTTP_202_ACCEPTED, + ) + async def update_mpa_agent( + mpa_instance_id: str, + body: MpaAgentOperationBody, + request: Request, + ) -> dict[str, Any]: + if operation_service is None: + raise HTTPException( + status_code=503, detail="mpa_operation_service_unavailable" + ) + idempotency_key = request.headers.get("Idempotency-Key", "").strip() + if not idempotency_key: + raise HTTPException( + status_code=400, + detail={ + "code": "idempotency_key_required", + "message": "idempotency_key_required", + }, + ) + try: + operation = await operation_service.start( + operation_request( + request=request, + mpa_instance_id=mpa_instance_id, + body=body, + operation_kind="update", + create=False, + ), + idempotency_key=idempotency_key, + ) + except MpaOperationConflict as error: + _raise_operation_conflict(error) + return _operation_payload(operation) + + @app.get("/web/mpa/agent-operations") + async def list_agent_operations( + request: Request, + status_filter: str = Query(default="active", alias="status"), + ) -> dict[str, Any]: + if operation_service is None: + raise HTTPException( + status_code=503, detail="mpa_operation_service_unavailable" + ) + if status_filter != "active": + raise HTTPException(status_code=400, detail="unsupported operation status") + operations = await operation_service.list_active(owner_id(request)) + return { + "operations": [_operation_payload(operation) for operation in operations] + } + + @app.get("/web/mpa/agent-operations/{operation_id}") + async def get_agent_operation( + operation_id: str, request: Request + ) -> dict[str, Any]: + if operation_service is None: + raise HTTPException( + status_code=503, detail="mpa_operation_service_unavailable" + ) + try: + operation = await operation_service.get(owner_id(request), operation_id) + except MpaOperationNotFound as error: + raise HTTPException( + status_code=404, + detail={ + "code": "mpa_operation_not_found", + "message": "mpa_operation_not_found", + }, + ) from error + return _operation_payload(operation) + + @app.get("/web/mpa/agents/{mpa_instance_id}/view") + async def mpa_agent_view( + mpa_instance_id: str, + request: Request, + response: Response, + runtime_id: str = Query(default="", alias="runtimeId"), + region: str = Query(default="all"), + ) -> dict[str, Any]: + owner = owner_id(request) + active_operation = await _active_operation( + operation_service, + owner, + mpa_instance_id, + ) + if runtime_id.strip(): + normalized_region = ( + region + if region.strip().lower() not in {"", "all", "*"} + else "cn-beijing" + ) + runtime = authorize_runtime(request, runtime_id.strip(), normalized_region) + bindings = [ + MpaRuntimeBinding( + runtime_id=runtime_id.strip(), + region=normalized_region, + runtime=runtime, + visible=_runtime_visible_payload( + runtime, + runtime_id.strip(), + normalized_region, + mpa_instance_id=mpa_instance_id, + ), + ) + ] + else: + bindings = await _runtime_bindings( + request=request, + mpa_instance_id=mpa_instance_id, + region=region, + authorize_runtime=authorize_runtime, + resolve_runtime_bindings=resolve_runtime_bindings, + ) + if not bindings: + return _mpa_agent_view_payload( + mpa_instance_id=mpa_instance_id, + binding_status="runtime_missing", + active_operation=active_operation, + safe_error_code="runtime_missing", + ) + if len(bindings) > 1: + return _mpa_agent_view_payload( + mpa_instance_id=mpa_instance_id, + binding_status="binding_ambiguous", + active_operation=active_operation, + bindings=[binding.visible for binding in bindings], + safe_error_code="binding_ambiguous", + ) + + binding = bindings[0] + try: + endpoint, bearer_token, _auth_type, _network_type = ( + resolve_runtime_connection( + request, + binding.runtime_id, + binding.region, + binding.runtime, + ) + ) + profile_status_result = await runtime_client.profile_status( + endpoint=endpoint, + mpa_instance_id=mpa_instance_id, + bearer_token=_bearer_value(bearer_token), + ) + except MpaRuntimeError as error: + if error.status_code == 404: + return _mpa_agent_view_payload( + mpa_instance_id=mpa_instance_id, + binding_status="orphan_runtime", + runtime=binding.visible, + active_operation=active_operation, + safe_error_code=error.code, + ) + if error.code == "runtime_legacy_auth_unsupported": + return _mpa_agent_view_payload( + mpa_instance_id=mpa_instance_id, + binding_status="bound", + runtime=binding.visible, + active_operation=active_operation, + safe_error_code=error.code, + ) + _raise_runtime_error(error) + payload = _result_payload(profile_status_result) + if etag := str(payload.get("etag") or ""): + response.headers["ETag"] = etag + return _mpa_agent_view_payload( + mpa_instance_id=mpa_instance_id, + binding_status="bound", + runtime=binding.visible, + profile=payload, + active_operation=active_operation, + ) + + @app.get("/web/mpa/agents/{mpa_instance_id}/delete-preview") + async def mpa_agent_delete_preview( + mpa_instance_id: str, + request: Request, + runtime_id: str = Query(default="", alias="runtimeId"), + region: str = Query(default="all"), + ) -> dict[str, Any]: + return await _mpa_agent_delete_preview_payload( + request=request, + mpa_instance_id=mpa_instance_id, + runtime_id=runtime_id, + region=region, + runtime_client=runtime_client, + authorize_runtime=authorize_runtime, + resolve_runtime_connection=resolve_runtime_connection, + operation_service=operation_service, + owner=owner_id(request), + resolve_runtime_bindings=resolve_runtime_bindings, + ) + + @app.post( + "/web/mpa/agent-operations/{operation_id}/retry", + status_code=status.HTTP_202_ACCEPTED, + ) + async def retry_agent_operation( + operation_id: str, + body: MpaAgentOperationBody, + request: Request, + ) -> dict[str, Any]: + if operation_service is None: + raise HTTPException( + status_code=503, detail="mpa_operation_service_unavailable" + ) + operation_kind = str(body.operation_kind or "").strip() or ( + "update" if body.runtime_revision.strip() else "create" + ) + if operation_kind not in {"create", "update"}: + raise HTTPException(status_code=400, detail="invalid operationKind") + mpa_instance_id = body.mpa_instance_id or ( + body.target_key if operation_kind == "update" else body.runtime_id + ) + if not mpa_instance_id: + raise HTTPException(status_code=400, detail="mpaInstanceId is required") + try: + operation = await operation_service.retry( + operation_request( + request=request, + mpa_instance_id=mpa_instance_id, + body=body, + operation_kind=operation_kind, + create=operation_kind == "create", + ), + operation_id=operation_id, + ) + except MpaOperationConflict as error: + _raise_operation_conflict(error) + return _operation_payload(operation) + + @app.put( + "/web/mpa/agents/{mpa_instance_id}/profile", + status_code=status.HTTP_202_ACCEPTED, + ) + async def apply_profile( + mpa_instance_id: str, + body: MpaProfileApplyBody, + request: Request, + response: Response, + ) -> dict[str, Any]: + idempotency_key = request.headers.get("Idempotency-Key", "").strip() + if not idempotency_key: + raise HTTPException( + status_code=400, + detail={ + "code": "idempotency_key_required", + "message": "idempotency_key_required", + }, + ) + if not body.create and not body.runtime_revision.strip(): + raise HTTPException( + status_code=400, + detail="runtimeRevision is required for update", + ) + runtime = authorize_runtime(request, body.runtime_id, body.region) + endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection( + request, + body.runtime_id, + body.region, + runtime, + ) + try: + result = await runtime_client.apply_profile( + endpoint=endpoint, + mpa_instance_id=mpa_instance_id, + bearer_token=_bearer_value(bearer_token), + idempotency_key=idempotency_key, + profile=MpaProfileApplyRequest.from_profile( + source_profile_id=body.source_profile_id, + profile=body.profile, + ), + create=body.create, + runtime_revision=body.runtime_revision.strip(), + ) + except MpaRuntimeError as error: + _raise_runtime_error(error) + payload = _result_payload(result) + if etag := str(payload.get("etag") or ""): + response.headers["ETag"] = etag + return payload + + @app.get("/web/mpa/agents/{mpa_instance_id}/profile-status") + async def profile_status( + mpa_instance_id: str, + request: Request, + response: Response, + runtime_id: str = Query(..., alias="runtimeId", min_length=1), + region: str = Query(default="cn-beijing"), + ) -> dict[str, Any]: + runtime = authorize_runtime(request, runtime_id, region) + endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection( + request, + runtime_id, + region, + runtime, + ) + try: + result = await runtime_client.profile_status( + endpoint=endpoint, + mpa_instance_id=mpa_instance_id, + bearer_token=_bearer_value(bearer_token), + ) + except MpaRuntimeError as error: + _raise_runtime_error(error) + payload = _result_payload(result) + if etag := str(payload.get("etag") or ""): + response.headers["ETag"] = etag + return payload + + @app.get("/web/mpa/sessions/{session_id}/execution-config") + async def get_session_execution_config( + session_id: str, + request: Request, + response: Response, + runtime_id: str = Query(..., alias="runtimeId", min_length=1), + region: str = Query(default="cn-beijing"), + ) -> dict[str, Any]: + runtime = authorize_runtime(request, runtime_id, region) + endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection( + request, + runtime_id, + region, + runtime, + ) + try: + result = await runtime_client.session_execution_config( + endpoint=endpoint, + session_id=session_id, + bearer_token=_bearer_value(bearer_token), + ) + except MpaRuntimeError as error: + _raise_runtime_error(error) + payload = _result_payload(result) + if etag := str(payload.get("etag") or ""): + response.headers["ETag"] = etag + return payload + + @app.patch("/web/mpa/sessions/{session_id}/execution-config") + async def patch_session_execution_config( + session_id: str, + body: MpaSessionExecutionConfigPatchBody, + request: Request, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict[str, Any]: + if not if_match: + raise HTTPException( + status_code=428, + detail={ + "code": "precondition_required", + "message": "precondition_required", + }, + ) + runtime = authorize_runtime(request, body.runtime_id, body.region) + endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection( + request, + body.runtime_id, + body.region, + runtime, + ) + try: + result = await runtime_client.patch_session_execution_config( + endpoint=endpoint, + session_id=session_id, + bearer_token=_bearer_value(bearer_token), + if_match=if_match, + changes=body.changes, + ) + except MpaRuntimeError as error: + _raise_runtime_error(error) + payload = _result_payload(result) + if etag := str(payload.get("etag") or ""): + response.headers["ETag"] = etag + return payload + + @app.post("/web/mpa/sessions/{session_id}/profile-upgrade") + async def upgrade_session_profile( + session_id: str, + body: MpaSessionProfileUpgradeBody, + request: Request, + response: Response, + idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), + if_match: str | None = Header(default=None, alias="If-Match"), + ) -> dict[str, Any]: + if not idempotency_key: + raise HTTPException( + status_code=400, + detail={ + "code": "idempotency_key_required", + "message": "idempotency_key_required", + }, + ) + if not if_match: + raise HTTPException( + status_code=428, + detail={ + "code": "precondition_required", + "message": "precondition_required", + }, + ) + runtime = authorize_runtime(request, body.runtime_id, body.region) + endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection( + request, + body.runtime_id, + body.region, + runtime, + ) + try: + result = await runtime_client.upgrade_session_profile( + endpoint=endpoint, + session_id=session_id, + bearer_token=_bearer_value(bearer_token), + if_match=if_match, + idempotency_key=idempotency_key, + target_profile_revision=body.target_profile_revision, + ) + except MpaRuntimeError as error: + _raise_runtime_error(error) + payload = _result_payload(result) + if etag := str(payload.get("etag") or ""): + response.headers["ETag"] = etag + return payload + + +def _bearer_value(value: str) -> str: + token = value.strip() + if token.lower().startswith("bearer "): + return token[7:].strip() + return token + + +def _result_payload(result: Any) -> dict[str, Any]: + if hasattr(result, "model_dump"): + return result.model_dump(mode="json", by_alias=True) + if isinstance(result, dict): + return dict(result) + raise HTTPException(status_code=502, detail="runtime_invalid_response") + + +def _raise_runtime_error(error: MpaRuntimeError) -> None: + detail = { + "code": error.code, + "message": error.code, + "requestId": error.request_id, + } + if error.current_state: + detail["currentState"] = error.current_state + raise HTTPException(status_code=error.status_code, detail=detail) from error + + +def _raise_operation_conflict(error: MpaOperationConflict) -> None: + raise HTTPException( + status_code=409, + detail={ + "code": "idempotency_mismatch", + "message": "idempotency_mismatch", + }, + ) from error + + +def _operation_payload(operation: Any) -> dict[str, Any]: + if hasattr(operation, "model_dump"): + return operation.model_dump(mode="json", by_alias=True) + if isinstance(operation, dict): + return dict(operation) + raise HTTPException(status_code=502, detail="mpa_operation_invalid_response") + + +async def _active_operation( + operation_service: MpaAgentOperationService | None, + owner_id: str, + mpa_instance_id: str, +) -> dict[str, Any] | None: + if operation_service is None: + return None + operations = await operation_service.list_active(owner_id) + for operation in operations: + payload = _operation_payload(operation) + if ( + str(payload.get("mpaInstanceId") or "") == mpa_instance_id + or str(payload.get("runtimeId") or "") == mpa_instance_id + or str(payload.get("targetKey") or "") == mpa_instance_id + ): + return payload + return None + + +def _operation_blocks_delete( + operation: dict[str, Any] | None, + *, + binding_status: str, + profile: dict[str, Any] | None, +) -> bool: + """Return whether a lifecycle operation still represents unsafe deletion work.""" + if not operation: + return False + status = str(operation.get("status") or "") + if status == "active": + return True + if status != "failed_retryable": + return False + if binding_status != "bound": + return True + if not profile: + return True + return str(profile.get("status") or "").casefold() != "applied" + + +async def _runtime_bindings( + *, + request: Request, + mpa_instance_id: str, + region: str, + authorize_runtime: RuntimeAuthorizer, + resolve_runtime_bindings: RuntimeBindingResolver | None, +) -> list[MpaRuntimeBinding]: + if resolve_runtime_bindings is None: + if region.strip().lower() in {"", "all", "*"}: + return [] + runtime = authorize_runtime(request, mpa_instance_id, region) + return [ + MpaRuntimeBinding( + runtime_id=mpa_instance_id, + region=region, + runtime=runtime, + visible=_runtime_visible_payload( + runtime, + mpa_instance_id, + region, + mpa_instance_id=mpa_instance_id, + ), + ) + ] + result = resolve_runtime_bindings(request, mpa_instance_id, region) + if inspect.isawaitable(result): + result = await result + return [ + _coerce_runtime_binding(item, mpa_instance_id, region) for item in result or [] + ] + + +async def _resolve_delete_binding( + *, + request: Request, + mpa_instance_id: str, + runtime_id: str, + region: str, + authorize_runtime: RuntimeAuthorizer, + resolve_runtime_bindings: RuntimeBindingResolver | None, +) -> tuple[str, MpaRuntimeBinding | None, list[MpaRuntimeBinding]]: + requested_runtime_id = runtime_id.strip() + if requested_runtime_id: + normalized_region = ( + region if region.strip().lower() not in {"", "all", "*"} else "cn-beijing" + ) + runtime = authorize_runtime(request, requested_runtime_id, normalized_region) + return ( + "bound", + MpaRuntimeBinding( + runtime_id=requested_runtime_id, + region=normalized_region, + runtime=runtime, + visible=_runtime_visible_payload( + runtime, + requested_runtime_id, + normalized_region, + mpa_instance_id=mpa_instance_id, + ), + ), + [], + ) + bindings = await _runtime_bindings( + request=request, + mpa_instance_id=mpa_instance_id, + region=region, + authorize_runtime=authorize_runtime, + resolve_runtime_bindings=resolve_runtime_bindings, + ) + if not bindings: + return "runtime_missing", None, [] + if len(bindings) > 1: + return "binding_ambiguous", None, bindings + return "bound", bindings[0], [] + + +async def _mpa_agent_delete_preview_payload( + *, + request: Request, + mpa_instance_id: str, + runtime_id: str, + region: str, + runtime_client: MpaRuntimeClient, + authorize_runtime: RuntimeAuthorizer, + resolve_runtime_connection: RuntimeConnectionResolver, + operation_service: MpaAgentOperationService | None, + owner: str, + resolve_runtime_bindings: RuntimeBindingResolver | None, +) -> dict[str, Any]: + active_operation = await _active_operation( + operation_service, owner, mpa_instance_id + ) + binding_status, binding, ambiguous_bindings = await _resolve_delete_binding( + request=request, + mpa_instance_id=mpa_instance_id, + runtime_id=runtime_id, + region=region, + authorize_runtime=authorize_runtime, + resolve_runtime_bindings=resolve_runtime_bindings, + ) + blockers: list[str] = [] + if binding_status != "bound": + blockers.append(binding_status) + if _operation_blocks_delete( + active_operation, + binding_status=binding_status, + profile=None, + ): + blockers.append("active_operation") + return _delete_preview_payload( + mpa_instance_id=mpa_instance_id, + binding_status=binding_status, + runtime=None, + profile=None, + active_operation=active_operation, + bindings=[item.visible for item in ambiguous_bindings], + blockers=blockers, + sessions=[], + ) + + assert binding is not None + profile: dict[str, Any] | None = None + sessions: list[Any] = [] + try: + endpoint, bearer_token, _auth_type, _network_type = resolve_runtime_connection( + request, + binding.runtime_id, + binding.region, + binding.runtime, + ) + profile = _result_payload( + await runtime_client.profile_status( + endpoint=endpoint, + mpa_instance_id=mpa_instance_id, + bearer_token=_bearer_value(bearer_token), + ) + ) + sessions = await runtime_client.list_sessions( + endpoint=endpoint, + bearer_token=_bearer_value(bearer_token), + include_a2a=True, + ) + except MpaRuntimeError as error: + if error.status_code == 404: + blockers.append("orphan_runtime") + binding_status = "orphan_runtime" + else: + _raise_runtime_error(error) + if _operation_blocks_delete( + active_operation, + binding_status=binding_status, + profile=profile, + ): + blockers.append("active_operation") + if _active_session_payloads(sessions): + blockers.append("active_sessions") + return _delete_preview_payload( + mpa_instance_id=mpa_instance_id, + binding_status=binding_status, + runtime=binding.visible, + profile=profile, + active_operation=active_operation, + bindings=[], + blockers=blockers, + sessions=sessions, + ) + + +def _session_payload(session: Any) -> dict[str, Any]: + if hasattr(session, "model_dump"): + payload = session.model_dump(mode="json", by_alias=True) + elif isinstance(session, dict): + payload = dict(session) + else: + payload = { + "sessionId": str( + getattr(session, "session_id", "") or getattr(session, "id", "") + ), + "status": str(getattr(session, "status", "") or ""), + "appName": str(getattr(session, "app_name", "") or ""), + } + session_id = str( + payload.get("sessionId") or payload.get("session_id") or payload.get("id") or "" + ) + result = { + "sessionId": session_id, + "status": str(payload.get("status") or ""), + "appName": str(payload.get("appName") or payload.get("app_name") or ""), + } + if bool(payload.get("adminDebug") or payload.get("admin_debug")): + result["adminDebug"] = True + return result + + +def _active_session_payloads(sessions: list[Any]) -> list[dict[str, Any]]: + return [ + payload + for payload in (_session_payload(session) for session in sessions) + if payload["status"].casefold() in _ACTIVE_SESSION_STATUSES + ] + + +def _delete_preview_payload( + *, + mpa_instance_id: str, + binding_status: str, + runtime: dict[str, Any] | None, + profile: dict[str, Any] | None, + active_operation: dict[str, Any] | None, + bindings: list[dict[str, Any]], + blockers: list[str], + sessions: list[Any], +) -> dict[str, Any]: + session_payloads = [_session_payload(session) for session in sessions] + active_sessions = _active_session_payloads(sessions) + idle_sessions = [ + item + for item in session_payloads + if item["status"].casefold() not in _ACTIVE_SESSION_STATUSES + ] + debug_count = sum(1 for item in session_payloads if item.get("adminDebug") is True) + can_delete = ( + binding_status == "bound" + and not blockers + and bool((runtime or {}).get("canManage", True)) + ) + return { + "mpaInstanceId": mpa_instance_id, + "bindingStatus": binding_status, + "runtime": runtime, + "profile": profile, + "activeOperation": active_operation, + "bindings": bindings, + "canDelete": can_delete, + "blockers": blockers, + "sessionCounts": { + "total": len(session_payloads), + "active": len(active_sessions), + "idle": len(idle_sessions), + "debug": debug_count, + }, + "activeSessions": active_sessions, + "cleanupPlan": [ + { + "stage": "runtime_sessions", + "description": "Delete visible idle Runtime sessions before deleting the Runtime resource.", + "count": len(idle_sessions), + }, + { + "stage": "runtime_diagnostics", + "description": "Runtime session cleanup removes associated diagnostics and mounted metadata.", + "count": len(session_payloads), + }, + { + "stage": "agentkit_runtime", + "description": "Delete the AgentKit Runtime resource after Runtime-side cleanup is complete.", + "count": 1 if binding_status in {"bound", "orphan_runtime"} else 0, + }, + ], + } + + +def _coerce_runtime_binding( + item: Any, + mpa_instance_id: str, + requested_region: str, +) -> MpaRuntimeBinding: + if isinstance(item, MpaRuntimeBinding): + return item + if isinstance(item, dict): + runtime = item.get("runtime") or item + runtime_id = str(item.get("runtimeId") or item.get("runtime_id") or "") + region = str(item.get("region") or requested_region or "") + visible = dict(item.get("visible") or item) + visible.pop("runtime", None) + resolved_runtime_id = runtime_id or str( + visible.get("runtimeId") or mpa_instance_id + ) + visible.setdefault("mpaInstanceId", mpa_instance_id) + return MpaRuntimeBinding( + runtime_id=resolved_runtime_id, + region=region, + runtime=runtime, + visible={**visible, "runtimeId": resolved_runtime_id, "region": region}, + ) + runtime_id = str(getattr(item, "runtime_id", "") or mpa_instance_id) + region = str(getattr(item, "region", "") or requested_region or "") + return MpaRuntimeBinding( + runtime_id=runtime_id, + region=region, + runtime=item, + visible=_runtime_visible_payload( + item, + runtime_id, + region, + mpa_instance_id=mpa_instance_id, + ), + ) + + +def _runtime_visible_payload( + runtime: Any, + runtime_id: str, + region: str, + *, + mpa_instance_id: str = "", +) -> dict[str, Any]: + return { + "runtimeId": runtime_id, + "region": region, + "mpaInstanceId": mpa_instance_id + or str(getattr(runtime, "mpa_instance_id", "") or ""), + "name": str(getattr(runtime, "name", "") or runtime_id), + "status": str(getattr(runtime, "status", "") or ""), + "currentVersion": getattr(runtime, "current_version_number", None), + "readinessPhase": str(getattr(runtime, "readiness_phase", "") or ""), + "agentCategory": "mpa", + } + + +def _mpa_agent_view_payload( + *, + mpa_instance_id: str, + binding_status: str, + runtime: dict[str, Any] | None = None, + profile: dict[str, Any] | None = None, + active_operation: dict[str, Any] | None = None, + bindings: list[dict[str, Any]] | None = None, + safe_error_code: str = "", +) -> dict[str, Any]: + can_manage = bool((runtime or {}).get("canManage", True)) + can_read = binding_status in {"bound", "orphan_runtime"} + auth_unsupported = safe_error_code == "runtime_legacy_auth_unsupported" + can_write = ( + binding_status in {"bound", "orphan_runtime"} + and can_manage + and not auth_unsupported + ) + operation_status = str((active_operation or {}).get("status") or "") + operation_blocks_debug = ( + operation_status in {"active", "failed_retryable"} or auth_unsupported + ) + return { + "mpaInstanceId": mpa_instance_id, + "bindingStatus": binding_status, + "runtime": runtime, + "profile": profile, + "activeOperation": active_operation, + "bindings": bindings or [], + "capabilities": { + "canRead": can_read, + "canWrite": can_write, + "canDebug": binding_status == "bound" and not operation_blocks_debug, + }, + "safeError": ( + {"code": safe_error_code, "message": safe_error_code} + if safe_error_code + else None + ), + } + + +__all__ = [ + "MpaAgentOperationBody", + "MpaProfileApplyBody", + "MpaRuntimeBinding", + "MpaRuntimeSessionBody", + "MpaSessionExecutionConfigPatchBody", + "MpaSessionProfileUpgradeBody", + "_ACTIVE_SESSION_STATUSES", + "_active_operation", + "_delete_preview_payload", + "_mpa_agent_delete_preview_payload", + "_operation_blocks_delete", + "_session_payload", + "mount_mpa_profile_routes", +] diff --git a/frontend/server/mpa/runtime_client.py b/frontend/server/mpa/runtime_client.py new file mode 100644 index 000000000..2409d1015 --- /dev/null +++ b/frontend/server/mpa/runtime_client.py @@ -0,0 +1,368 @@ +# 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. + +"""Typed Studio-to-mpa-agent Profile transport.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import httpx +from pydantic import BaseModel, ConfigDict, Field + +from veadk.integrations.mpa.control_plane_client import ( + MpaExecutionConfigChange, + MpaProfile, + MpaSessionExecutionConfig, +) + + +class MpaProfileApplyRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + source_profile_id: str = Field(alias="sourceProfileId", min_length=1) + source_profile_digest: str = Field(alias="sourceProfileDigest") + profile: MpaProfile + + @classmethod + def from_profile( + cls, *, source_profile_id: str, profile: MpaProfile + ) -> "MpaProfileApplyRequest": + canonical = json.dumps( + profile.model_dump(by_alias=True), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return cls( + sourceProfileId=source_profile_id, + sourceProfileDigest=f"sha256:{hashlib.sha256(canonical).hexdigest()}", + profile=profile, + ) + + +class MpaProfileResult(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + operation_id: str = Field(alias="operationId") + status: str + etag: str = "" + + +class MpaRuntimeSessionSummary(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="allow") + + id: str = "" + session_id: str = Field(default="", alias="sessionId") + app_name: str = Field(default="", alias="appName") + user_id: str = Field(default="", alias="userId") + status: str = "" + updated_at: str = Field(default="", alias="updatedAt") + mpa_instance_id: str = Field(default="", alias="mpaInstanceId") + profile_revision: int | None = Field(default=None, alias="profileRevision") + admin_debug: bool = Field(default=False, alias="adminDebug") + + @property + def stable_id(self) -> str: + return self.session_id or self.id + + +class MpaRuntimeError(RuntimeError): + def __init__( + self, + code: str, + *, + status_code: int, + request_id: str = "", + current_state: dict[str, Any] | None = None, + ) -> None: + super().__init__("MPA Runtime request failed") + self.code = code + self.status_code = status_code + self.request_id = request_id + self.current_state = current_state + + +def _runtime_error_code( + payload: dict[str, Any], *, method: str, url: str, status_code: int +) -> str: + error = payload.get("error") if isinstance(payload, dict) else {} + detail = payload.get("detail") if isinstance(payload, dict) else {} + code = error.get("code") if isinstance(error, dict) else None + if code is None and isinstance(detail, dict): + code = detail.get("code") + if code is None and isinstance(detail, str): + if "X-Jwt-Token" in detail: + return "runtime_legacy_auth_unsupported" + if ( + status_code == 404 + and method.upper() == "GET" + and url.rstrip("/").endswith("/profile-status") + ): + return "profile_not_found" + if detail.strip(): + return detail.strip() + return str(code or "runtime_request_failed") + + +class MpaRuntimeClient: + def __init__(self, *, transport: httpx.AsyncClient | None = None) -> None: + self._transport = transport + + async def apply_profile( + self, + *, + endpoint: str, + mpa_instance_id: str, + bearer_token: str, + idempotency_key: str, + profile: MpaProfileApplyRequest, + create: bool = False, + runtime_revision: str = "", + ) -> MpaProfileResult: + if create and runtime_revision: + raise ValueError("create cannot carry a runtime revision") + if not create and not runtime_revision: + raise ValueError("update requires a runtime revision") + headers = { + "Authorization": f"Bearer {bearer_token}", + "Idempotency-Key": idempotency_key, + "If-None-Match" if create else "If-Match": "*" + if create + else runtime_revision, + } + response = await self._request( + "PUT", + f"{endpoint.rstrip('/')}/api/v1/agents/{mpa_instance_id}/profile", + headers=headers, + json=profile.model_dump(by_alias=True), + ) + return self._result(response) + + async def profile_status( + self, *, endpoint: str, mpa_instance_id: str, bearer_token: str + ) -> MpaProfileResult: + response = await self._request( + "GET", + f"{endpoint.rstrip('/')}/api/v1/agents/{mpa_instance_id}/profile-status", + headers={"Authorization": f"Bearer {bearer_token}"}, + ) + return self._result(response) + + async def execution_smoke( + self, + *, + endpoint: str, + mpa_instance_id: str, + bearer_token: str, + idempotency_key: str, + ) -> dict[str, Any]: + del mpa_instance_id + response = await self._request( + "POST", + f"{endpoint.rstrip('/')}/api/v1/readiness/execution-smoke", + headers={ + "Authorization": f"Bearer {bearer_token}", + "Idempotency-Key": idempotency_key, + }, + json={}, + ) + payload = response.json() + if not isinstance(payload, dict): + raise MpaRuntimeError("runtime_invalid_response", status_code=502) + return payload + + async def session_execution_config( + self, + *, + endpoint: str, + session_id: str, + bearer_token: str, + ) -> MpaSessionExecutionConfig: + response = await self._request( + "GET", + f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}/execution-config", + headers={"Authorization": f"Bearer {bearer_token}"}, + ) + return self._execution_config_result(response) + + async def list_sessions( + self, + *, + endpoint: str, + bearer_token: str, + include_a2a: bool = True, + ) -> list[MpaRuntimeSessionSummary]: + response = await self._request( + "GET", + f"{endpoint.rstrip('/')}/api/v1/sessions", + headers={"Authorization": f"Bearer {bearer_token}"}, + params={"include_a2a": "true" if include_a2a else "false"}, + ) + return self._session_list_result(response) + + async def delete_session( + self, + *, + endpoint: str, + session_id: str, + bearer_token: str, + ) -> None: + await self._request( + "DELETE", + f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}", + headers={"Authorization": f"Bearer {bearer_token}"}, + ) + + async def patch_session_execution_config( + self, + *, + endpoint: str, + session_id: str, + bearer_token: str, + if_match: str, + changes: list[MpaExecutionConfigChange], + ) -> MpaSessionExecutionConfig: + response = await self._request( + "PATCH", + f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}/execution-config", + headers={ + "Authorization": f"Bearer {bearer_token}", + "If-Match": if_match, + }, + json={"changes": [change.model_dump(mode="json") for change in changes]}, + ) + return self._execution_config_result(response) + + async def upgrade_session_profile( + self, + *, + endpoint: str, + session_id: str, + bearer_token: str, + if_match: str, + idempotency_key: str, + target_profile_revision: int, + ) -> MpaSessionExecutionConfig: + response = await self._request( + "POST", + f"{endpoint.rstrip('/')}/api/v1/sessions/{session_id}/profile-upgrade", + headers={ + "Authorization": f"Bearer {bearer_token}", + "If-Match": if_match, + "Idempotency-Key": idempotency_key, + }, + json={"targetProfileRevision": int(target_profile_revision)}, + ) + return self._execution_config_result(response) + + async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response: + try: + if self._transport is not None: + response = await self._transport.request(method, url, **kwargs) + else: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.request(method, url, **kwargs) + except httpx.HTTPError as error: + raise MpaRuntimeError("runtime_unavailable", status_code=503) from error + if response.status_code >= 400: + try: + payload = response.json() + except ValueError: + payload = {} + detail = payload.get("detail") if isinstance(payload, dict) else {} + error = payload.get("error") if isinstance(payload, dict) else {} + current_state = None + if isinstance(error, dict) and isinstance(error.get("currentState"), dict): + current_state = dict(error["currentState"]) + elif isinstance(detail, dict) and isinstance( + detail.get("currentState"), dict + ): + current_state = dict(detail["currentState"]) + raise MpaRuntimeError( + _runtime_error_code( + payload, + method=method, + url=url, + status_code=response.status_code, + ), + status_code=response.status_code, + request_id=response.headers.get("X-Request-Id", ""), + current_state=current_state, + ) + return response + + @staticmethod + def _result(response: httpx.Response) -> MpaProfileResult: + try: + payload = response.json() + if not isinstance(payload, dict): + raise ValueError + payload = dict(payload) + payload["etag"] = response.headers.get( + "ETag", str(payload.get("etag") or "") + ) + return MpaProfileResult(**payload) + except (ValueError, TypeError) as error: + raise MpaRuntimeError( + "runtime_invalid_response", status_code=502 + ) from error + + @staticmethod + def _execution_config_result(response: httpx.Response) -> MpaSessionExecutionConfig: + try: + payload = response.json() + if not isinstance(payload, dict): + raise ValueError + payload = dict(payload) + payload["etag"] = response.headers.get( + "ETag", str(payload.get("etag") or "") + ) + return MpaSessionExecutionConfig(**payload) + except (ValueError, TypeError) as error: + raise MpaRuntimeError( + "runtime_invalid_response", status_code=502 + ) from error + + @staticmethod + def _session_list_result( + response: httpx.Response, + ) -> list[MpaRuntimeSessionSummary]: + try: + payload = response.json() + if isinstance(payload, list): + items = payload + elif isinstance(payload, dict): + raw_items = ( + payload.get("sessions") + or payload.get("items") + or payload.get("data") + or [] + ) + if not isinstance(raw_items, list): + raise ValueError + items = raw_items + else: + raise ValueError + return [ + MpaRuntimeSessionSummary(**item) + for item in items + if isinstance(item, dict) + ] + except (ValueError, TypeError) as error: + raise MpaRuntimeError( + "runtime_invalid_response", status_code=502 + ) from error diff --git a/frontend/server/mpa/service.py b/frontend/server/mpa/service.py new file mode 100644 index 000000000..0b4693326 --- /dev/null +++ b/frontend/server/mpa/service.py @@ -0,0 +1,323 @@ +# 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. + +"""MPA Agent lifecycle orchestration for Studio.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any, Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field + +from frontend.server.mpa.operations import ( + MpaLifecycleOperation, + MpaOperationConflict, + Stored, +) +from frontend.server.mpa.runtime_client import ( + MpaProfile, + MpaProfileApplyRequest, + MpaRuntimeClient, + MpaRuntimeError, +) + + +class MpaOperationRepository(Protocol): + async def create_operation( + self, operation: MpaLifecycleOperation + ) -> Stored[MpaLifecycleOperation]: ... + + async def get_operation( + self, owner_id: str, operation_id: str + ) -> Stored[MpaLifecycleOperation]: ... + + async def update_operation( + self, + operation: MpaLifecycleOperation, + etag: str, + ) -> Stored[MpaLifecycleOperation]: ... + + async def list_active_operations( + self, owner_id: str + ) -> list[MpaLifecycleOperation]: ... + + +class MpaRuntimeOrchestrationClient(Protocol): + async def apply_profile(self, **kwargs: Any) -> Any: ... + + async def execution_smoke(self, **kwargs: Any) -> Any: ... + + +class MpaAgentOperationRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) + + owner_id: str = Field(alias="ownerId", min_length=1) + operation_kind: Literal["create", "update"] = Field(alias="operationKind") + target_key: str = Field(alias="targetKey", min_length=1) + runtime_id: str = Field(alias="runtimeId", min_length=1) + runtime_region: str = Field(default="cn-beijing", alias="runtimeRegion") + runtime_endpoint: str = Field(alias="runtimeEndpoint", min_length=1) + runtime_authorization: str = Field(alias="runtimeAuthorization", min_length=1) + mpa_instance_id: str = Field(alias="mpaInstanceId", min_length=1) + source_profile_id: str = Field(alias="sourceProfileId", min_length=1) + profile: MpaProfile + create: bool = False + runtime_revision: str = Field(default="", alias="runtimeRevision") + + +class MpaAgentOperationService: + """Drive MPA create/update operation stages using idempotent Runtime APIs.""" + + def __init__( + self, + *, + repository: MpaOperationRepository, + runtime_client: MpaRuntimeOrchestrationClient | MpaRuntimeClient, + now: Callable[[], datetime] | None = None, + ) -> None: + self._repository = repository + self._runtime_client = runtime_client + self._now = now or (lambda: datetime.now(timezone.utc)) + + async def start( + self, + request: MpaAgentOperationRequest, + *, + idempotency_key: str, + ) -> MpaLifecycleOperation: + operation = MpaLifecycleOperation( + operationKind=request.operation_kind, + ownerId=request.owner_id, + targetKey=request.target_key, + idempotencyKeyHash=_hash_text(idempotency_key), + requestHash=_request_hash(request), + stage=( + "runtime_preparing" + if request.operation_kind == "create" + else "profile_applying" + ), + status="active", + mpaInstanceId=request.mpa_instance_id, + runtimeId=request.runtime_id, + runtimeRegion=request.runtime_region, + runtimeRevision=request.runtime_revision, + profileDigest=_profile_apply_request(request).source_profile_digest, + createdAt=self._now(), + updatedAt=self._now(), + ) + stored = await self._repository.create_operation(operation) + if not stored.created: + return stored.value + if stored.value.status not in {"active", "failed_retryable"}: + return stored.value + if ( + stored.value.request_hash != operation.request_hash + or stored.value.idempotency_key_hash != operation.idempotency_key_hash + ): + raise MpaOperationConflict( + "MPA operation idempotency key was reused with different input." + ) + if stored.value.stage not in { + "runtime_preparing", + "profile_applying", + "smoke_running", + }: + return stored.value + return await self._advance(stored, request) + + async def retry( + self, + request: MpaAgentOperationRequest, + *, + operation_id: str, + ) -> MpaLifecycleOperation: + stored = await self._repository.get_operation(request.owner_id, operation_id) + if stored.value.status != "failed_retryable": + return stored.value + return await self.resume(request, operation_id=operation_id) + + async def resume( + self, + request: MpaAgentOperationRequest, + *, + operation_id: str, + ) -> MpaLifecycleOperation: + stored = await self._repository.get_operation(request.owner_id, operation_id) + if stored.value.request_hash != _request_hash(request): + raise MpaOperationConflict("MPA operation request does not match.") + if stored.value.status not in {"active", "failed_retryable"}: + return stored.value + next_operation = stored.value.model_copy( + update={ + "status": "active", + "safe_error_code": "", + "retry_count": stored.value.retry_count + 1, + "updated_at": self._now(), + } + ) + stored = await self._repository.update_operation(next_operation, stored.etag) + return await self._advance(stored, request) + + async def list_active(self, owner_id: str) -> list[MpaLifecycleOperation]: + return await self._repository.list_active_operations(owner_id) + + async def get(self, owner_id: str, operation_id: str) -> MpaLifecycleOperation: + stored = await self._repository.get_operation(owner_id, operation_id) + return stored.value + + async def _advance( + self, + stored: Stored[MpaLifecycleOperation], + request: MpaAgentOperationRequest, + ) -> MpaLifecycleOperation: + operation = stored.value + etag = stored.etag + try: + if operation.stage == "runtime_preparing": + operation, etag = await self._store_stage( + operation, etag, stage="profile_applying" + ) + if operation.stage == "profile_applying": + profile_result = await self._runtime_client.apply_profile( + endpoint=request.runtime_endpoint, + mpa_instance_id=request.mpa_instance_id, + bearer_token=_bearer_value(request.runtime_authorization), + idempotency_key=f"{operation.operation_id}-profile", + profile=_profile_apply_request(request), + create=request.create, + runtime_revision=request.runtime_revision, + ) + profile_payload = _payload(profile_result) + operation, etag = await self._store_stage( + operation, + etag, + stage=( + "smoke_running" + if request.operation_kind == "create" + else "succeeded" + ), + profile_operation_id=str(profile_payload.get("operationId") or ""), + profile_revision=_int_or_none( + profile_payload.get("profileRevision") + ), + runtime_revision=str( + profile_payload.get("runtimeRevision") + or profile_payload.get("etag") + or "" + ), + ) + if request.operation_kind == "update": + operation, _etag = await self._store_stage( + operation, etag, status="succeeded", stage="succeeded" + ) + return operation + if operation.stage == "smoke_running": + smoke_result = await self._runtime_client.execution_smoke( + endpoint=request.runtime_endpoint, + mpa_instance_id=request.mpa_instance_id, + bearer_token=_bearer_value(request.runtime_authorization), + idempotency_key=f"{operation.operation_id}-smoke", + ) + smoke_payload = _payload(smoke_result) + if str(smoke_payload.get("status") or "") != "passed": + raise MpaRuntimeError( + str( + smoke_payload.get("lastErrorCode") or "not_execution_ready" + ), + status_code=503, + ) + operation, _etag = await self._store_stage( + operation, etag, status="succeeded", stage="runnable" + ) + return operation + except MpaRuntimeError as error: + failed, _etag = await self._store_stage( + operation, + etag, + status="failed_retryable", + safe_error_code=error.code, + ) + return failed + + async def _store_stage( + self, + operation: MpaLifecycleOperation, + etag: str, + **changes: Any, + ) -> tuple[MpaLifecycleOperation, str]: + updated = operation.model_copy( + update={ + **changes, + "updated_at": self._now(), + } + ) + stored = await self._repository.update_operation(updated, etag) + return stored.value, stored.etag + + +def _profile_apply_request( + request: MpaAgentOperationRequest, +) -> MpaProfileApplyRequest: + return MpaProfileApplyRequest.from_profile( + source_profile_id=request.source_profile_id, + profile=request.profile, + ) + + +def _request_hash(request: MpaAgentOperationRequest) -> str: + payload = request.model_dump(mode="json", by_alias=True) + payload.pop("runtimeEndpoint", None) + payload.pop("runtimeAuthorization", None) + return _hash_json(payload) + + +def _hash_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _hash_json(value: Any) -> str: + return hashlib.sha256( + json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + + +def _payload(value: Any) -> dict[str, Any]: + if hasattr(value, "model_dump"): + return value.model_dump(mode="json", by_alias=True) + if isinstance(value, dict): + return value + return {} + + +def _int_or_none(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _bearer_value(value: str) -> str: + token = value.strip() + if token.lower().startswith("bearer "): + return token[7:].strip() + return token + + +__all__ = ["MpaAgentOperationRequest", "MpaAgentOperationService"] diff --git a/frontend/server/mpa/test_scenarios.py b/frontend/server/mpa/test_scenarios.py new file mode 100644 index 000000000..702764b13 --- /dev/null +++ b/frontend/server/mpa/test_scenarios.py @@ -0,0 +1,1654 @@ +# 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. + +"""Loopback-only scenario controls for browser contract tests.""" + +from __future__ import annotations + +import asyncio +import hmac +import json +import os +import stat +import threading +import time +from copy import deepcopy +from pathlib import Path +from re import fullmatch +from typing import Any + +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import BaseModel, Field + + +class ScenarioRequest(BaseModel): + barriers: list[str] = Field(default_factory=list) + + +class _ScenarioState: + def __init__(self) -> None: + self._condition = threading.Condition() + self._active = "" + self._scenarios: dict[str, dict[str, Any]] = {} + + def replace(self, scenario: str, barriers: list[str]) -> dict[str, Any]: + with self._condition: + state = { + "barriers": {name: "blocked" for name in barriers}, + "calls": {}, + "data": _default_scenario_data(scenario), + } + self._scenarios[scenario] = state + self._active = scenario + self._condition.notify_all() + return self._copy(state) + + def get(self, scenario: str) -> dict[str, Any]: + with self._condition: + state = self._scenarios.get(scenario) + if state is None: + raise KeyError(scenario) + return self._copy(state) + + def active(self) -> tuple[str, dict[str, Any]] | None: + with self._condition: + if not self._active: + return None + state = self._scenarios.get(self._active) + if state is None: + return None + return self._active, self._copy(state) + + def names(self) -> list[str]: + with self._condition: + return sorted(self._scenarios) + + def count(self, scenario: str, call: str) -> int: + with self._condition: + state = self._require(scenario) + calls = state["calls"] + calls[call] = int(calls.get(call, 0)) + 1 + self._condition.notify_all() + return int(calls[call]) + + def append_payload(self, scenario: str, key: str, payload: dict[str, Any]) -> None: + with self._condition: + state = self._require(scenario) + payloads = state["data"].setdefault("payloads", {}) + items = payloads.setdefault(key, []) + items.append(deepcopy(payload)) + self._condition.notify_all() + + def mark_operation_complete(self, scenario: str) -> None: + with self._condition: + state = self._require(scenario) + state["data"]["operationComplete"] = True + self._condition.notify_all() + + def release(self, scenario: str, barrier: str) -> None: + with self._condition: + state = self._require(scenario) + if barrier not in state["barriers"]: + raise KeyError(barrier) + state["barriers"][barrier] = "released" + if barrier == "operation_complete": + state["data"]["operationComplete"] = True + if scenario == "turn_lifecycle": + participants = state["data"].setdefault("participants", {}) + participant_id = { + "participant_ack": "primary", + "primary_ack": "primary", + "worker_1_ack": "worker-1", + "worker_2_ack": "worker-2", + }.get(barrier) + if participant_id and participant_id in participants: + participants[participant_id] = "paused" + self._condition.notify_all() + + def wait_if_blocked( + self, scenario: str, barrier: str, timeout: float = 30.0 + ) -> None: + with self._condition: + state = self._require(scenario) + if barrier not in state["barriers"]: + return + deadline = time.monotonic() + timeout + while state["barriers"].get(barrier) != "released": + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(barrier) + self._condition.wait(timeout=remaining) + + def control(self, scenario: str) -> dict[str, Any]: + with self._condition: + state = self._require(scenario) + return deepcopy(state["data"]["turnControl"]) + + def control_action( + self, + scenario: str, + action: str, + *, + idempotency_key: str, + expected_generation: int, + ) -> tuple[dict[str, Any], int]: + with self._condition: + state = self._require(scenario) + data = state["data"] + control = data["turnControl"] + calls = state["calls"] + key = f"{action}:{idempotency_key}" + replays = data.setdefault("controlReplays", {}) + if key in replays: + calls[f"control-{action}-replay"] = ( + int(calls.get(f"control-{action}-replay", 0)) + 1 + ) + return deepcopy(replays[key]), 200 + calls[f"control-{action}"] = int(calls.get(f"control-{action}", 0)) + 1 + payload = { + "action": action, + "expectedGeneration": expected_generation, + "idempotencyKey": idempotency_key, + } + data.setdefault("payloads", {}).setdefault("control", []).append(payload) + if expected_generation != int(control["generation"]): + return deepcopy(control), 409 + if action == "pause": + if control["state"] != "running": + return deepcopy(control), 409 + control.update( + { + "state": "pausing", + "generation": int(control["generation"]) + 1, + "desiredState": "paused", + "allowedActions": [], + "resumeDisposition": "same_turn", + } + ) + data["participants"] = { + "primary": "running", + "worker-1": "running", + "worker-2": "running", + } + replays[key] = deepcopy(control) + self._condition.notify_all() + return deepcopy(control), 200 + if action == "resume": + if control.get("resumeDisposition") == "new_turn_required": + return deepcopy(control), 200 + if control["state"] != "paused": + return deepcopy(control), 409 + control.update( + { + "state": "running", + "generation": int(control["generation"]) + 1, + "desiredState": "running", + "safePoint": None, + "checkpoint": None, + "allowedActions": ["pause"], + "resumeDisposition": "same_turn", + "continuationPrompt": None, + } + ) + data["participants"] = { + "primary": "running", + "worker-1": "running", + "worker-2": "running", + } + replays[key] = deepcopy(control) + self._condition.notify_all() + return deepcopy(control), 200 + raise ValueError(action) + + def maybe_advance_pause(self, scenario: str) -> None: + with self._condition: + state = self._require(scenario) + control = state["data"]["turnControl"] + participants = state["data"].get("participants", {}) + if ( + control["state"] == "pausing" + and participants + and all(value == "paused" for value in participants.values()) + ): + control.update( + { + "state": "paused", + "desiredState": "paused", + "safePoint": "after_tool", + "checkpoint": {"participants": sorted(participants)}, + "allowedActions": ["resume"], + "pausedAt": "2026-09-15T00:00:00Z", + "resumableUntil": "2026-09-15T00:05:00Z", + "resumeDisposition": "same_turn", + } + ) + self._condition.notify_all() + + def seed_new_turn_required(self, scenario: str) -> dict[str, Any]: + with self._condition: + state = self._require(scenario) + control = state["data"]["turnControl"] + control.update( + { + "state": "interrupted", + "generation": int(control["generation"]) + 1, + "desiredState": "running", + "allowedActions": ["resume"], + "pausedAt": "2026-09-15T00:00:00Z", + "resumableUntil": "2026-09-15T00:05:00Z", + "resumeDisposition": "new_turn_required", + "continuationPrompt": ( + "Continue from the last safe point without repeating " + "completed side effects." + ), + } + ) + self._condition.notify_all() + return deepcopy(control) + + def continue_turn( + self, + scenario: str, + *, + idempotency_key: str, + expected_generation: int, + ) -> tuple[dict[str, Any], int]: + with self._condition: + state = self._require(scenario) + data = state["data"] + control = data["turnControl"] + calls = state["calls"] + key = f"continue:{idempotency_key}" + replays = data.setdefault("continuationReplays", {}) + if key in replays: + calls["runtime-continue-replay"] = ( + int(calls.get("runtime-continue-replay", 0)) + 1 + ) + return deepcopy(replays[key]), 200 + calls["runtime-continue"] = int(calls.get("runtime-continue", 0)) + 1 + data.setdefault("payloads", {}).setdefault("continue", []).append( + { + "expectedGeneration": expected_generation, + "idempotencyKey": idempotency_key, + } + ) + if expected_generation != int(control["generation"]): + return deepcopy(control), 409 + if control.get("resumeDisposition") != "new_turn_required": + return { + "code": "turn_not_continuable", + "message": "turn_not_continuable", + }, 409 + result = { + "taskId": control["taskId"], + "sessionId": data["sessionId"], + "invocationId": f"{scenario}-continued-invocation", + "turnId": f"{scenario}-continued-turn", + "operationId": f"{scenario}-continued-operation", + "executionConfigRevision": data["executionConfig"]["revision"], + "continuationOf": f"{scenario}-turn", + "idempotentReplay": False, + } + replays[key] = {**result, "idempotentReplay": True} + data.setdefault("events", []).append( + { + "id": "event-after-continue", + "invocationId": result["invocationId"], + "author": data["appName"], + "partial": False, + "content": { + "role": "model", + "parts": [{"text": "Continued after safe point"}], + }, + "turnComplete": True, + } + ) + self._condition.notify_all() + return result, 200 + + def execution_config(self) -> dict[str, Any]: + with self._condition: + state = self._require_active_session() + return deepcopy(state["data"]["executionConfig"]) + + def session_events(self) -> list[dict[str, Any]]: + with self._condition: + state = self._require_active_session() + return deepcopy(state["data"].get("events", [])) + + def persist_session_event(self, event: dict[str, Any]) -> None: + with self._condition: + state = self._require_active_session() + events = state["data"].setdefault("events", []) + event_id = str(event.get("id") or "") + if event_id and any( + str(item.get("id") or "") == event_id for item in events + ): + return + events.append(deepcopy(event)) + self._condition.notify_all() + + def update_execution_config( + self, + *, + if_match: str, + changes: list[dict[str, Any]], + ) -> tuple[dict[str, Any], bool]: + with self._condition: + state = self._require_active_session() + config = state["data"]["executionConfig"] + if if_match != config["etag"]: + return deepcopy(config), False + next_revision = int(config["revision"]) + 1 + overrides = deepcopy(config["overrides"]) + effective_refs = deepcopy(config["effectiveRefs"]) + for change in changes: + category = str(change.get("category") or "") + mode = str(change.get("mode") or "") + if category == "model": + if mode == "inherit": + overrides.pop("model", None) + effective_refs["model"] = { + "id": _profile_model_for(config["profileRevision"]) + } + elif mode == "replace": + value = change.get("value") + model_id = ( + str(value.get("id") or "").strip() + if isinstance(value, dict) + else "" + ) + if model_id: + overrides["model"] = { + "mode": mode, + "value": {"id": model_id}, + } + effective_refs["model"] = {"id": model_id} + elif category == "mcpServers": + if mode == "clear": + overrides["mcpServers"] = {"mode": "clear"} + effective_refs["mcpServers"] = [] + elif mode == "inherit": + overrides.pop("mcpServers", None) + effective_refs["mcpServers"] = _profile_mcp_for( + config["profileRevision"] + ) + config.update( + { + "revision": next_revision, + "etag": f'"{next_revision}"', + "overrides": overrides, + "effectiveRefs": effective_refs, + "updatedAt": "2026-09-15T01:00:00Z", + } + ) + self._condition.notify_all() + return deepcopy(config), True + + def upgrade_profile( + self, + *, + if_match: str, + idempotency_key: str, + target_profile_revision: int, + ) -> tuple[dict[str, Any], int, str]: + with self._condition: + state = self._require_active_session() + data = state["data"] + upgrades = data.setdefault("profileUpgrades", {}) + prior = upgrades.get(idempotency_key) + if prior is not None: + if int(prior["targetProfileRevision"]) != target_profile_revision: + return ( + deepcopy(data["executionConfig"]), + 409, + "idempotency_mismatch", + ) + return deepcopy(prior["result"]), 200, "" + config = data["executionConfig"] + if if_match != config["etag"]: + return deepcopy(config), 412, "execution_config_changed" + latest = int(data["latestProfileRevision"]) + current = int(config["profileRevision"]) + if target_profile_revision <= current: + return deepcopy(config), 409, "downgrade_not_allowed" + if target_profile_revision != latest: + return deepcopy(config), 409, "profile_revision_unavailable" + next_revision = int(config["revision"]) + 1 + effective_refs = deepcopy(config["effectiveRefs"]) + if "model" not in config["overrides"]: + effective_refs["model"] = { + "id": _profile_model_for(target_profile_revision) + } + if "mcpServers" not in config["overrides"]: + effective_refs["mcpServers"] = _profile_mcp_for(target_profile_revision) + config.update( + { + "revision": next_revision, + "etag": f'"{next_revision}"', + "profileRevision": target_profile_revision, + "profileDefaultRevision": target_profile_revision, + "effectiveRefs": effective_refs, + "updatedAt": "2026-09-15T01:00:00Z", + } + ) + result = deepcopy(config) + upgrades[idempotency_key] = { + "targetProfileRevision": target_profile_revision, + "result": result, + } + self._condition.notify_all() + return result, 200, "" + + def _require(self, scenario: str) -> dict[str, Any]: + state = self._scenarios.get(scenario) + if state is None: + raise KeyError(scenario) + return state + + def _require_active_session(self) -> dict[str, Any]: + if self._active not in { + "session_revision_6", + "cursor_expired", + "turn_lifecycle", + }: + raise KeyError(self._active) + return self._require(self._active) + + @staticmethod + def _copy(state: dict[str, Any]) -> dict[str, Any]: + return { + "barriers": dict(state["barriers"]), + "calls": dict(state["calls"]), + "data": deepcopy(state["data"]), + } + + +def _read_token(path: Path) -> str: + mode = stat.S_IMODE(path.stat().st_mode) + if mode & 0o077: + raise ValueError("scenario token file must have mode 0600") + token = path.read_text(encoding="utf-8").strip() + if not token: + raise ValueError("scenario token file must not be empty") + return token + + +def _profile_model_for(profile_revision: int) -> str: + return "model-upgraded" if profile_revision >= 7 else "model-base" + + +def _profile_mcp_for(profile_revision: int) -> list[dict[str, str]]: + if profile_revision >= 7: + return [{"id": "mcp-upgraded"}] + return [{"id": "mcp-default"}] + + +def _default_execution_config(session_id: str = "session-s2") -> dict[str, Any]: + return { + "appName": "a2a-default", + "sessionId": session_id, + "revision": 6, + "etag": '"6"', + "mpaInstanceId": "runtime-mpa-s2", + "profileRevision": 6, + "profileDefaultRevision": 6, + "overrides": {}, + "effectiveRefs": { + "model": {"id": _profile_model_for(6)}, + "mcpServers": _profile_mcp_for(6), + }, + "invalidRefs": [], + "updatedBy": "scenario-user", + "createdAt": "2026-09-15T00:00:00Z", + "updatedAt": "2026-09-15T00:00:00Z", + } + + +def _default_scenario_data(scenario: str) -> dict[str, Any]: + session_id = ( + "session-s2" if scenario == "session_revision_6" else f"{scenario}-session" + ) + data = { + "runtimeId": "runtime-mpa-s2", + "runtimeName": "MPA S2 Fixture", + "region": "cn-beijing", + "appName": "a2a-default", + "sessionId": session_id, + "profileRevision": 6, + "latestProfileRevision": 7, + "executionConfig": _default_execution_config(session_id), + "profileUpgrades": {}, + "events": [], + "payloads": {}, + "turnControl": { + "taskId": f"{scenario}-task", + "state": "running", + "generation": 0, + "desiredState": "running", + "safePoint": None, + "checkpoint": None, + "allowedActions": ["pause"], + "idempotentReplay": False, + "pausedAt": None, + "resumableUntil": None, + "resumeDisposition": "same_turn", + "continuationPrompt": None, + }, + "participants": { + "primary": "running", + "worker-1": "running", + "worker-2": "running", + }, + } + if scenario == "cursor_expired": + data["events"] = [ + { + "id": "event-before-refresh", + "author": data["appName"], + "partial": False, + "content": { + "role": "model", + "parts": [{"text": "Before refresh"}], + }, + "turnComplete": True, + } + ] + return data + + +def _runtime_item(data: dict[str, Any]) -> dict[str, Any]: + return { + "name": data["runtimeName"], + "runtimeId": data["runtimeId"], + "status": "Ready", + "createdAt": "2026-09-15T00:00:00Z", + "description": "Browser scenario MPA Runtime", + "cpuMilli": 2000, + "memoryMb": 4096, + "currentVersion": data["latestProfileRevision"], + "agentCategory": "mpa", + "region": data["region"], + "author": "scenario-user", + "isMine": True, + "canDelete": False, + "canManage": True, + "canPublish": False, + "visibility": "private", + "reviewStatus": "", + } + + +def _agent_info(data: dict[str, Any]) -> dict[str, Any]: + return { + "appName": data["appName"], + "name": data["runtimeName"], + "description": "Browser scenario MPA Agent", + "type": "llm", + "model": _profile_model_for(data["profileRevision"]), + "selectableModels": ["model-base", "model-upgraded", "model-browser-a"], + "turnLifecycleControl": ( + { + "actions": ["pause", "resume"], + "pauseMode": "cooperative-safe-point", + "processReplacementResume": False, + } + if data["sessionId"].startswith("turn_lifecycle-") + else None + ), + "resourceTopology": None, + "tools": [], + "skills": [], + "skillsPreviewSupported": True, + "subAgents": [], + "components": [], + "searchSources": [], + } + + +def _adk_session( + data: dict[str, Any], user_id: str = "scenario-user" +) -> dict[str, Any]: + return { + "id": data["sessionId"], + "userId": user_id, + "lastUpdateTime": 1789490000, + "events": deepcopy(data.get("events", [])), + "state": {}, + } + + +_CREATION_FAILURES = { + "profile_apply_failed": ("profile_applying", "profile_apply_failed"), + "smoke_worker_not_ready": ("smoke_running", "smoke_worker_not_ready"), + "smoke_output_mismatch": ("smoke_running", "smoke_output_mismatch"), + "cleanup_failed": ("smoke_running", "cleanup_failed"), +} + + +def _scenario_operation( + scenario: str, data: dict[str, Any], *, retried: bool = False +) -> dict[str, Any]: + operation = { + "operationId": f"mpaop-{scenario}", + "operationKind": "create", + "ownerId": "scenario-user", + "targetKey": f"runtime:{data['region']}:{data['runtimeName']}", + "stage": "runnable", + "status": "succeeded", + "mpaInstanceId": data["runtimeId"], + "runtimeId": data["runtimeId"], + "runtimeRegion": data["region"], + "runtimeRevision": str(data["latestProfileRevision"]), + "profileRevision": data["latestProfileRevision"], + "profileOperationId": "scenario-profile", + "retryCount": 1 if retried else 0, + } + if scenario in _CREATION_FAILURES and not retried: + stage, error_code = _CREATION_FAILURES[scenario] + operation.update( + { + "stage": stage, + "status": "failed_retryable", + "safeErrorCode": error_code, + "profileRevision": ( + None + if scenario == "profile_apply_failed" + else data["latestProfileRevision"] + ), + } + ) + return operation + + +def mount_test_scenario_routes(app: FastAPI, *, token_file: Path) -> bool: + """Mount test routes only when both isolation switches are explicit.""" + if not ( + os.environ.get("APP_ENV") == "test" + and os.environ.get("VEADK_MPA_TEST_SCENARIOS") == "1" + ): + return False + token = _read_token(token_file) + state = _ScenarioState() + + def authorize_loopback(request: Request) -> None: + remote = request.headers.get("X-MPA-Test-Remote-Addr") + if remote is None and request.client is not None: + remote = request.client.host + if remote not in {"127.0.0.1", "::1", "testclient"}: + raise HTTPException(status_code=403, detail="loopback_required") + + def authorize(request: Request) -> None: + authorize_loopback(request) + supplied = request.headers.get("Authorization", "") + expected = f"Bearer {token}" + if not hmac.compare_digest(supplied, expected): + raise HTTPException(status_code=401, detail="invalid_scenario_token") + + def active_mpa_scenario( + request: Request, + ) -> tuple[str, dict[str, Any]] | None: + authorize_loopback(request) + active = state.active() + if active is None: + return None + return active[0], active[1]["data"] + + def active_s2_data(request: Request) -> dict[str, Any] | None: + active = active_mpa_scenario(request) + if active is None or active[0] != "session_revision_6": + return None + return active[1] + + def active_mpa_data(request: Request) -> dict[str, Any] | None: + active = active_mpa_scenario(request) + if active is None: + return None + return active[1] + + def current_state_response( + config: dict[str, Any], status_code: int, code: str + ) -> JSONResponse: + return JSONResponse( + { + "detail": { + "code": code, + "message": code, + "requestId": "scenario-request", + "currentState": config, + } + }, + status_code=status_code, + ) + + @app.get("/web/runtime-name-availability") + def fixture_runtime_name_availability( + request: Request, name: str = "", region: str = "" + ) -> dict[str, bool]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, _data = active + state.count(scenario, "runtime-name-availability") + state.append_payload( + scenario, + "runtime-name-availability", + {"name": name, "region": region}, + ) + return {"available": True} + + @app.post("/web/generated-agent-projects") + async def fixture_generated_agent_projects(request: Request) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, _data = active + payload = await request.json() + state.count(scenario, "generated-agent-projects") + state.append_payload(scenario, "generated-agent-projects", payload) + draft = payload.get("draft") if isinstance(payload, dict) else {} + name = str(draft.get("name") or "scenario-agent") + project_name = name.strip().lower().replace("-", "_").replace(" ", "_") + return { + "name": project_name or "scenario_agent", + "files": [ + { + "path": "app.py", + "content": "# browser scenario project\n", + } + ], + } + + @app.post("/web/generated-agent-drafts") + async def fixture_generated_agent_drafts(request: Request) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, _data = active + payload = await request.json() + state.count(scenario, "generated-agent-drafts") + state.append_payload(scenario, "generated-agent-drafts", payload) + return { + "draft": { + "name": "ime_generated", + "description": "IME scenario", + "instruction": "Generated after composition ends.", + "agentType": "llm", + "subAgents": [], + }, + "summary": "IME scenario generated", + "unresolvedItems": [], + } + + @app.post("/web/deploy-agentkit") + async def fixture_deploy_agentkit(request: Request) -> StreamingResponse: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, data = active + payload = await request.json() + state.count(scenario, "deploy-agentkit") + state.append_payload(scenario, "deploy-agentkit", payload) + + async def body(): + progress = { + "level": "info", + "phase": "deploy", + "message": "Scenario deployment isolated", + "pct": 50, + "runtimeName": data["runtimeName"], + } + final = { + "done": True, + "success": True, + "agentName": data["appName"], + "runtimeName": data["runtimeName"], + "runtimeId": data["runtimeId"], + "mpaInstanceId": data["runtimeId"], + "region": data["region"], + "version": data["latestProfileRevision"], + } + yield f"data: {json.dumps(progress)}\n\n".encode("utf-8") + try: + await asyncio.to_thread( + state.wait_if_blocked, scenario, "deploy_complete" + ) + except TimeoutError: + return + yield f"data: {json.dumps(final)}\n\n".encode("utf-8") + + return StreamingResponse(body(), media_type="text/event-stream") + + @app.post("/web/mpa/agents", status_code=202) + async def fixture_create_mpa_agent(request: Request) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, data = active + payload = await request.json() + state.count(scenario, "mpa-agent-create") + state.append_payload(scenario, "mpa-agent-create", payload) + if scenario == "create_success": + try: + await asyncio.to_thread( + state.wait_if_blocked, scenario, "operation_complete" + ) + except TimeoutError as error: + raise HTTPException( + status_code=504, detail="scenario_barrier_timeout" + ) from error + state.mark_operation_complete(scenario) + if "operation_complete" in state.get(scenario)["barriers"]: + state.count(scenario, "mpa-operation-complete") + return _scenario_operation(scenario, data) + + @app.get("/web/mpa/agent-operations") + def fixture_list_mpa_operations( + request: Request, status: str = "" + ) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + return {"operations": []} + scenario, data = active + state.count(scenario, "mpa-operation-list") + scenario_state = state.get(scenario) + if scenario_state["calls"].get("mpa-agent-create", 0) == 0: + return {"operations": []} + operation_complete = bool( + scenario_state["data"].get("operationComplete") + or scenario_state["calls"].get("mpa-operation-complete", 0) > 0 + ) + operation = _scenario_operation( + scenario, + data, + retried=scenario_state["calls"].get("mpa-operation-retry-complete", 0) > 0, + ) + if scenario == "create_success" and not operation_complete: + operation.update({"status": "active", "stage": "profile_applying"}) + if status == "active" and operation["status"] not in { + "active", + "failed_retryable", + }: + return {"operations": []} + return {"operations": [operation]} + + @app.get("/web/mpa/agent-operations/{operation_id}") + def fixture_get_mpa_operation( + operation_id: str, request: Request + ) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, data = active + scenario_state = state.get(scenario) + if scenario_state["calls"].get("mpa-agent-create", 0) == 0: + raise HTTPException(status_code=404, detail="mpa_operation_not_found") + operation_complete = bool( + scenario_state["data"].get("operationComplete") + or scenario_state["calls"].get("mpa-operation-complete", 0) > 0 + ) + operation = _scenario_operation( + scenario, + data, + retried=scenario_state["calls"].get("mpa-operation-retry-complete", 0) > 0, + ) + if scenario == "create_success" and not operation_complete: + operation.update({"status": "active", "stage": "profile_applying"}) + if operation_id != operation["operationId"]: + raise HTTPException(status_code=404, detail="mpa_operation_not_found") + state.count(scenario, "mpa-operation-get") + return operation + + @app.post("/web/mpa/agent-operations/{operation_id}/retry", status_code=202) + async def fixture_retry_mpa_operation( + operation_id: str, request: Request + ) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, data = active + operation = _scenario_operation(scenario, data) + if operation_id != operation["operationId"]: + raise HTTPException(status_code=404, detail="mpa_operation_not_found") + payload = await request.json() + state.count(scenario, "mpa-operation-retry") + state.append_payload(scenario, "mpa-operation-retry", payload) + try: + await asyncio.to_thread(state.wait_if_blocked, scenario, "retry_complete") + except TimeoutError as error: + raise HTTPException( + status_code=504, detail="scenario_barrier_timeout" + ) from error + state.count(scenario, "mpa-operation-retry-complete") + return _scenario_operation(scenario, data, retried=True) + + @app.get("/web/runtimes") + def fixture_runtimes( + request: Request, + agentCategory: str = "", + scope: str = "", + region: str = "", + ) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + return {"runtimes": [], "nextToken": ""} + scenario, data = active + category = agentCategory.strip().lower() + if category and category != "mpa": + return {"runtimes": [], "nextToken": ""} + if scenario == "slow_scope_switch": + user = request.headers.get("X-VeADK-Local-User", "").strip() + if scope != "mine": + raise HTTPException(status_code=404, detail="resource_not_found") + if user == "alice" and region == "cn-beijing": + state.count(scenario, "runtime-list-old") + try: + state.wait_if_blocked(scenario, "old_scope_response") + except TimeoutError as error: + raise HTTPException( + status_code=504, detail="scenario_barrier_timeout" + ) from error + item = _runtime_item( + { + **data, + "runtimeId": "runtime-same-id", + "runtimeName": "MPA Scope Alice", + "region": "cn-beijing", + } + ) + return {"runtimes": [item], "nextToken": ""} + if user == "bob" and region == "cn-shanghai": + state.count(scenario, "runtime-list-current") + item = _runtime_item( + { + **data, + "runtimeId": "runtime-same-id", + "runtimeName": "MPA Scope Bob", + "region": "cn-shanghai", + } + ) + return {"runtimes": [item], "nextToken": ""} + state.count(scenario, "runtime-list-denied") + raise HTTPException(status_code=404, detail="resource_not_found") + state.count(scenario, "runtime-list") + return {"runtimes": [_runtime_item(data)], "nextToken": ""} + + @app.get("/web/mpa/agents/{mpa_instance_id}/view") + def fixture_mpa_agent_view( + mpa_instance_id: str, + request: Request, + ) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + scenario, data = active + if scenario == "slow_scope_switch": + user = request.headers.get("X-VeADK-Local-User", "").strip() + if user not in {"alice", "bob"}: + state.count(scenario, "mpa-view-denied") + raise HTTPException(status_code=404, detail="resource_not_found") + if mpa_instance_id != data["runtimeId"]: + raise HTTPException(status_code=404, detail="runtime_not_found") + + runtime = _runtime_item(data) + profile = { + "operationId": "scenario-profile", + "status": "applied", + "profileRevision": data["latestProfileRevision"], + "runtimeRevision": str(data["latestProfileRevision"]), + "etag": f'"{data["latestProfileRevision"]}"', + } + binding_status = "bound" + bindings: list[dict[str, Any]] = [] + safe_error = None + can_read = True + can_write = True + can_debug = True + active_operation = None + if scenario in _CREATION_FAILURES: + scenario_state = state.get(scenario) + recovered = ( + scenario_state["calls"].get("mpa-operation-retry-complete", 0) > 0 + ) + if not recovered: + active_operation = _scenario_operation(scenario, data) + can_debug = False + if scenario == "runtime_missing": + binding_status = "runtime_missing" + runtime = None + profile = None + can_read = can_write = can_debug = False + safe_error = {"code": "runtime_missing", "message": "runtime_missing"} + elif scenario == "binding_ambiguous": + binding_status = "binding_ambiguous" + bindings = [ + runtime, + { + **runtime, + "runtimeId": "runtime-mpa-s2-secondary", + "region": "cn-shanghai", + }, + ] + runtime = None + profile = None + can_read = can_write = can_debug = False + safe_error = {"code": "binding_ambiguous", "message": "binding_ambiguous"} + elif scenario == "orphan_runtime": + binding_status = "orphan_runtime" + profile = None + can_debug = False + safe_error = {"code": "profile_not_found", "message": "profile_not_found"} + elif scenario == "old_runtime": + runtime = {**runtime, "currentVersion": 1} + can_write = can_debug = False + + return { + "mpaInstanceId": mpa_instance_id, + "bindingStatus": binding_status, + "runtime": runtime, + "profile": profile, + "activeOperation": active_operation, + "bindings": bindings, + "capabilities": { + "canRead": can_read, + "canWrite": can_write, + "canDebug": can_debug, + }, + "safeError": safe_error, + } + + @app.get("/web/runtime-detail") + def fixture_runtime_detail( + request: Request, + runtimeId: str = "", + region: str = "", + ) -> dict[str, Any]: + data = active_mpa_data(request) + if data is None or runtimeId != data["runtimeId"]: + raise HTTPException(status_code=404, detail="runtime_not_found") + return { + "runtimeId": data["runtimeId"], + "name": data["runtimeName"], + "description": "Browser scenario MPA Runtime", + "status": "Ready", + "statusMessage": "", + "model": _profile_model_for(data["profileRevision"]), + "project": "mpa-s2-browser", + "region": region or data["region"], + "createdAt": "2026-09-15T00:00:00Z", + "updatedAt": "2026-09-15T00:00:00Z", + "currentVersion": data["latestProfileRevision"], + "resources": {}, + "envs": [], + "memoryId": "", + "toolId": "", + "knowledgeId": "", + "mcpToolsetId": "", + "artifactUrl": "", + "artifactType": "", + "networkTypes": ["public"], + "endpoint": "http://127.0.0.1/scenario-runtime", + "authType": "custom_jwt", + } + + @app.get("/web/runtime-update-capability") + def fixture_runtime_update_capability( + request: Request, + runtimeId: str = "", + region: str = "", + appName: str = "", + ) -> dict[str, Any]: + data = active_mpa_data(request) + if data is None or runtimeId != data["runtimeId"]: + raise HTTPException(status_code=404, detail="runtime_not_found") + return { + "canUpdate": True, + "reason": "", + "recoveryStatus": "complete", + "editMode": "source-preserving", + "recoverySource": "agent-info", + "warnings": [], + "etag": '"7"', + "runtime": { + "runtimeId": data["runtimeId"], + "name": data["runtimeName"], + "region": region or data["region"], + "currentVersion": data["latestProfileRevision"], + "envs": [], + "configuredEnvKeys": [], + "network": {"type": "public"}, + }, + "agent": _agent_info({**data, "appName": appName or data["appName"]}), + } + + @app.get("/web/workspaces") + def fixture_workspaces(request: Request) -> dict[str, Any]: + if active_mpa_data(request) is None: + return {"items": []} + return {"items": []} + + @app.get("/web/v3/environments") + def fixture_environments(request: Request) -> dict[str, Any]: + if active_mpa_data(request) is None: + return {"items": []} + return {"items": []} + + @app.get("/web/runtime-tool-channel/{runtime_id}/capabilities") + def fixture_runtime_tool_capabilities( + runtime_id: str, + request: Request, + ) -> dict[str, Any]: + data = active_mpa_data(request) + if data is None or runtime_id != data["runtimeId"]: + raise HTTPException(status_code=404, detail="runtime_not_found") + return { + "enabled": False, + "supported": False, + "tools": [], + } + + @app.get("/web/evaluation/statuses") + def fixture_evaluation_statuses( + request: Request, + runtimeId: str = "", + region: str = "", + appName: str = "", + userId: str = "", + ) -> dict[str, Any]: + data = active_mpa_data(request) + if data is None or runtimeId != data["runtimeId"]: + raise HTTPException(status_code=404, detail="runtime_not_found") + return { + "runtimeId": runtimeId, + "region": region or data["region"], + "appName": appName or data["appName"], + "userId": userId, + "items": [], + } + + @app.post("/web/runtime-route-channel/{runtime_id}/connect") + def fixture_runtime_route_channel( + runtime_id: str, + request: Request, + ) -> dict[str, Any]: + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="runtime_not_found") + scenario, data = active + if data is None or runtime_id != data["runtimeId"]: + raise HTTPException(status_code=404, detail="runtime_not_found") + state.count(scenario, "route-channel-connect") + return { + "enabled": True, + "supported": True, + "connected": True, + "catalogRevision": "scenario", + } + + @app.api_route( + "/web/runtime-proxy/{runtime_id}/{path:path}", + methods=["GET", "HEAD", "POST", "PATCH", "DELETE"], + ) + async def fixture_runtime_proxy(runtime_id: str, path: str, request: Request): + active = active_mpa_scenario(request) + if active is None: + raise HTTPException(status_code=404, detail="runtime_not_found") + scenario, data = active + if data is None or runtime_id != data["runtimeId"]: + raise HTTPException(status_code=404, detail="runtime_not_found") + if request.method == "GET" and path == "list-apps": + state.count(scenario, "list-apps") + return [data["appName"]] + if request.method == "GET" and path == f"web/agent-info/{data['appName']}": + return _agent_info(data) + if request.method == "GET" and path == f"web/agent-draft/{data['appName']}": + return { + "draft": { + "name": data["runtimeName"], + "description": "Browser scenario MPA Agent", + "instruction": "Use the scenario fixture.", + "modelName": _profile_model_for(data["profileRevision"]), + "agentType": "llm", + } + } + profile_status_match = fullmatch( + rf"api/v1/agents/{data['runtimeId']}/profile-status", + path, + ) + if request.method == "GET" and profile_status_match: + state.count(scenario, "runtime-profile-status") + return { + "operationId": "scenario-profile", + "status": "applied", + "profileRevision": data["latestProfileRevision"], + "runtimeRevision": str(data["latestProfileRevision"]), + "etag": f'"{data["latestProfileRevision"]}"', + } + if request.method == "GET" and path == "api/v1/sessions": + state.count(scenario, "runtime-session-list") + return [ + { + "sessionId": data["sessionId"], + "id": data["sessionId"], + "appName": data["appName"], + "userId": "scenario-user", + "mpaInstanceId": data["runtimeId"], + "profileRevision": data["profileRevision"], + } + ] + if request.method == "POST" and path == "api/v1/sessions": + state.count(scenario, "runtime-session-create") + payload = await request.json() + if scenario == "cursor_expired": + state.append_payload(scenario, "session-create", payload) + return { + "sessionId": data["sessionId"], + "id": data["sessionId"], + "appName": data["appName"], + "userId": "scenario-user", + "mpaInstanceId": data["runtimeId"], + "profileRevision": payload.get( + "profileRevision", data["profileRevision"] + ), + } + session_detail_match = fullmatch( + rf"api/v1/sessions/{data['sessionId']}", + path, + ) + if request.method == "GET" and session_detail_match: + state.count(scenario, "runtime-session-read") + return _adk_session(data) + session_events_match = fullmatch( + rf"api/v1/sessions/{data['sessionId']}/events", + path, + ) + if request.method == "GET" and session_events_match: + state.count(scenario, "runtime-session-events") + return {"events": deepcopy(data.get("events", []))} + control_by_session_match = fullmatch( + rf"api/v1/a2a/tasks/by-session/{data['sessionId']}/control", + path, + ) + if request.method == "GET" and control_by_session_match: + state.count(scenario, "runtime-control-status") + state.maybe_advance_pause(scenario) + return state.control(scenario) + control_by_task_match = fullmatch( + rf"api/v1/a2a/tasks/{data['turnControl']['taskId']}/control", + path, + ) + if request.method == "GET" and control_by_task_match: + state.count(scenario, "runtime-control-status") + state.maybe_advance_pause(scenario) + return state.control(scenario) + control_action_match = fullmatch( + rf"api/v1/a2a/tasks/by-session/{data['sessionId']}/control/(pause|resume)", + path, + ) or fullmatch( + rf"api/v1/a2a/tasks/{data['turnControl']['taskId']}/control/(pause|resume)", + path, + ) + if request.method == "POST" and control_action_match: + payload = await request.json() + idempotency_key = request.headers.get("Idempotency-Key", "").strip() + result, status_code = state.control_action( + scenario, + control_action_match.group(1), + idempotency_key=idempotency_key, + expected_generation=int(payload.get("expectedGeneration", -1)), + ) + if status_code >= 400: + return JSONResponse({"detail": result}, status_code=status_code) + return result + continue_match = fullmatch( + rf"api/v1/a2a/tasks/{data['turnControl']['taskId']}/continue", + path, + ) + if request.method == "POST" and continue_match: + payload = await request.json() + idempotency_key = request.headers.get("Idempotency-Key", "").strip() + result, status_code = state.continue_turn( + scenario, + idempotency_key=idempotency_key, + expected_generation=int(payload.get("expectedGeneration", -1)), + ) + if status_code >= 400: + return JSONResponse({"detail": result}, status_code=status_code) + return result + sessions_match = fullmatch( + rf"apps/{data['appName']}/users/([^/]+)/sessions(?:/([^/]+))?", + path, + ) + if sessions_match: + user_id = sessions_match.group(1) + requested_session_id = sessions_match.group(2) + if requested_session_id and requested_session_id != data["sessionId"]: + raise HTTPException(status_code=404, detail="session_not_found") + if request.method == "POST": + state.count(scenario, "session-create") + return _adk_session(data, user_id) + if request.method == "GET": + return ( + _adk_session(data, user_id) + if requested_session_id + else [_adk_session(data, user_id)] + ) + run_match = fullmatch( + rf"api/v1/sessions/{data['sessionId']}/run", + path, + ) + if request.method == "POST" and run_match: + state.count(scenario, "runtime-run") + payload = await request.json() + if scenario == "cursor_expired": + state.append_payload(scenario, "run", payload) + return { + "sessionId": data["sessionId"], + "invocationId": f"{scenario}-invocation", + "turnId": f"{scenario}-turn", + "operationId": f"{scenario}-operation", + "executionConfigRevision": int( + payload.get("executionConfigVersion") + or data["executionConfig"]["revision"] + ), + } + sse_match = fullmatch( + rf"api/v1/sessions/{data['sessionId']}/sse", + path, + ) + if request.method == "POST" and sse_match: + state.count(scenario, "runtime-sse") + payload = await request.json() + if scenario == "cursor_expired": + state.append_payload(scenario, "sse", payload) + last_event_id = str(payload.get("lastEventId") or "") + known_event_ids = { + str(event.get("id")) + for event in state.session_events() + if event.get("id") + } + if last_event_id and last_event_id not in known_event_ids: + return JSONResponse( + { + "detail": { + "code": "cursor_expired", + "message": "cursor_expired", + } + }, + status_code=410, + ) + + async def body(): + if ( + scenario == "turn_lifecycle" + and payload.get("invocationId") + == "turn_lifecycle-continued-invocation" + ): + yield ( + 'data: {"partial":false,"id":"event-after-continue",' + '"invocationId":"turn_lifecycle-continued-invocation",' + '"content":{"parts":[{"text":"Continued after safe point"}]},' + '"turnComplete":true}\n\n' + ).encode("utf-8") + return + events = ( + state.session_events() + if scenario in {"cursor_expired", "turn_lifecycle"} + else [] + ) + last_event_id = str(payload.get("lastEventId") or "") + if not last_event_id: + for event in events: + yield f"data: {json.dumps(event)}\n\n".encode("utf-8") + event = { + "id": "event-after-refresh", + "invocationId": f"{scenario}-invocation", + "author": data["appName"], + "partial": False, + "content": { + "role": "model", + "parts": [{"text": "After refresh"}], + }, + "turnComplete": True, + } + if scenario == "turn_lifecycle": + running_event = { + "id": "event-lifecycle-running", + "invocationId": "turn_lifecycle-invocation", + "author": data["appName"], + "partial": True, + "content": { + "role": "model", + "parts": [{"text": "Lifecycle task running"}], + }, + "turnComplete": False, + } + yield f"data: {json.dumps(running_event)}\n\n".encode("utf-8") + try: + await asyncio.to_thread( + state.wait_if_blocked, + scenario, + "stream_complete", + ) + except TimeoutError: + return + if scenario == "cursor_expired": + state.persist_session_event(event) + yield f"data: {json.dumps(event)}\n\n".encode("utf-8") + + return StreamingResponse(body(), media_type="text/event-stream") + if request.method == "POST" and path == "run_sse": + state.count(scenario, "run-sse") + + async def body(): + event = { + "id": "scenario-event-1", + "author": data["appName"], + "partial": False, + "content": { + "role": "model", + "parts": [{"text": "Scenario response"}], + }, + "turnComplete": True, + } + yield f"data: {json.dumps(event)}\\n\\n".encode("utf-8") + + return StreamingResponse(body(), media_type="text/event-stream") + raise HTTPException(status_code=404, detail="scenario_runtime_path_not_found") + + @app.get("/web/mpa/agents/{mpa_instance_id}/profile-status") + def fixture_profile_status( + mpa_instance_id: str, + request: Request, + response: Response, + ) -> dict[str, Any]: + data = active_mpa_data(request) + if data is None or mpa_instance_id != data["runtimeId"]: + raise HTTPException(status_code=404, detail="profile_status_not_found") + etag = f'"{data["latestProfileRevision"]}"' + response.headers["ETag"] = etag + return { + "operationId": "scenario-profile", + "status": "applied", + "profileRevision": data["latestProfileRevision"], + "runtimeRevision": str(data["latestProfileRevision"]), + "etag": etag, + } + + @app.get("/web/mpa/sessions/{session_id}/execution-config") + def fixture_get_execution_config( + session_id: str, + request: Request, + response: Response, + ) -> dict[str, Any]: + data = active_mpa_data(request) + if data is None or session_id != data["sessionId"]: + raise HTTPException(status_code=404, detail="execution_config_not_found") + config = state.execution_config() + response.headers["ETag"] = config["etag"] + return config + + @app.patch("/web/mpa/sessions/{session_id}/execution-config") + async def fixture_patch_execution_config( + session_id: str, + request: Request, + response: Response, + ) -> Any: + data = active_s2_data(request) + if data is None or session_id != data["sessionId"]: + raise HTTPException(status_code=404, detail="execution_config_not_found") + if_match = request.headers.get("If-Match", "").strip() + if not if_match: + raise HTTPException( + status_code=428, + detail={ + "code": "precondition_required", + "message": "precondition_required", + }, + ) + payload = await request.json() + changes = payload.get("changes") if isinstance(payload, dict) else None + if not isinstance(changes, list): + raise HTTPException(status_code=400, detail="changes must be an array") + call = "config-patch" + if any( + isinstance(change, dict) and change.get("category") == "model" + for change in changes + ): + call = "config-patch-a" + elif any( + isinstance(change, dict) and change.get("category") == "mcpServers" + for change in changes + ): + call = "config-patch-b" + state.count("session_revision_6", call) + try: + state.wait_if_blocked("session_revision_6", call) + except TimeoutError as error: + raise HTTPException( + status_code=504, detail="scenario_barrier_timeout" + ) from error + config, updated = state.update_execution_config( + if_match=if_match, + changes=changes, + ) + if not updated: + return current_state_response(config, 412, "execution_config_changed") + response.headers["ETag"] = config["etag"] + return config + + @app.post("/web/mpa/sessions/{session_id}/profile-upgrade") + async def fixture_profile_upgrade( + session_id: str, + request: Request, + response: Response, + ) -> Any: + data = active_s2_data(request) + if data is None or session_id != data["sessionId"]: + raise HTTPException(status_code=404, detail="execution_config_not_found") + if_match = request.headers.get("If-Match", "").strip() + idempotency_key = request.headers.get("Idempotency-Key", "").strip() + if not idempotency_key: + raise HTTPException( + status_code=400, + detail={ + "code": "idempotency_key_required", + "message": "idempotency_key_required", + }, + ) + if not if_match: + raise HTTPException( + status_code=428, + detail={ + "code": "precondition_required", + "message": "precondition_required", + }, + ) + payload = await request.json() + target_revision = ( + int(payload.get("targetProfileRevision", 0)) + if isinstance(payload, dict) + else 0 + ) + state.count("session_revision_6", "profile-upgrade") + try: + state.wait_if_blocked("session_revision_6", "profile-upgrade") + except TimeoutError as error: + raise HTTPException( + status_code=504, detail="scenario_barrier_timeout" + ) from error + config, status_code, code = state.upgrade_profile( + if_match=if_match, + idempotency_key=idempotency_key, + target_profile_revision=target_revision, + ) + if status_code == 412: + return current_state_response(config, status_code, code) + if status_code >= 400: + raise HTTPException( + status_code=status_code, + detail={"code": code, "message": code}, + ) + response.headers["ETag"] = config["etag"] + return config + + @app.get("/__test/mpa/scenarios") + def list_scenarios(request: Request) -> dict[str, Any]: + authorize(request) + return {"scenarios": state.names()} + + @app.put("/__test/mpa/scenarios/{scenario}") + def put_scenario( + scenario: str, payload: ScenarioRequest, request: Request + ) -> dict[str, Any]: + authorize(request) + return state.replace(scenario, payload.barriers) + + @app.get("/__test/mpa/scenarios/{scenario}") + def get_scenario(scenario: str, request: Request) -> dict[str, Any]: + authorize(request) + try: + return state.get(scenario) + except KeyError as error: + raise HTTPException(status_code=404, detail="scenario_not_found") from error + + @app.post("/__test/mpa/scenarios/{scenario}/calls/{call}") + def record_call(scenario: str, call: str, request: Request) -> dict[str, int]: + authorize(request) + try: + return {"count": state.count(scenario, call)} + except KeyError as error: + raise HTTPException(status_code=404, detail="scenario_not_found") from error + + @app.post("/__test/mpa/scenarios/{scenario}/barriers/{barrier}/release") + def release_barrier( + scenario: str, barrier: str, request: Request + ) -> dict[str, bool]: + authorize(request) + try: + state.release(scenario, barrier) + except KeyError as error: + raise HTTPException(status_code=404, detail="barrier_not_found") from error + return {"released": True} + + @app.post("/__test/mpa/scenarios/{scenario}/new-turn-required") + def seed_new_turn_required(scenario: str, request: Request) -> dict[str, Any]: + authorize(request) + try: + return state.seed_new_turn_required(scenario) + except KeyError as error: + raise HTTPException(status_code=404, detail="scenario_not_found") from error + + return True diff --git a/frontend/server/mpa_a2a.py b/frontend/server/mpa_a2a.py new file mode 100644 index 000000000..fb8bf601f --- /dev/null +++ b/frontend/server/mpa_a2a.py @@ -0,0 +1,51 @@ +# 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. + +"""Compatibility for MPA agent cards behind shared Runtime gateway paths.""" + +import re +from urllib.parse import urlsplit, urlunsplit + + +def mpa_a2a_rpc_url(endpoint: str, advertised_url: str) -> str: + """Restore only the known default RPC path on a same-origin Runtime gateway.""" + try: + base = urlsplit(endpoint) + card = urlsplit(advertised_url) + if ( + base.scheme not in {"http", "https"} + or base.scheme != card.scheme + or not base.hostname + or base.hostname != card.hostname + or (base.port or (443 if base.scheme == "https" else 80)) + != (card.port or (443 if card.scheme == "https" else 80)) + or any( + ( + base.username is not None, + card.username is not None, + base.query, + card.query, + base.fragment, + card.fragment, + ) + ) + or not re.fullmatch(r"/runtime/[a-z0-9-]+", base.path.rstrip("/")) + or card.path != "/a2a/jsonrpc" + ): + return advertised_url + return urlunsplit( + (base.scheme, base.netloc, base.path.rstrip("/") + card.path, "", "") + ) + except ValueError: + return advertised_url diff --git a/frontend/server/mpa_agent_info.py b/frontend/server/mpa_agent_info.py new file mode 100644 index 000000000..47d1d04a1 --- /dev/null +++ b/frontend/server/mpa_agent_info.py @@ -0,0 +1,134 @@ +# 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. + +"""Read-only, allowlisted metadata for the Studio MPA information rail.""" + +from typing import Any + +import httpx + + +def _bound_spaces(card: dict[str, Any] | None, region: str) -> dict[str, Any]: + unavailable = {"skillSpaces": [], "skillSpacesStatus": "unsupported"} + capabilities = (card or {}).get("capabilities") + if not isinstance(capabilities, dict): + return unavailable + extensions = capabilities.get("extensions") + if not isinstance(extensions, list): + return unavailable + topology = next( + ( + item.get("params") + for item in extensions + if isinstance(item, dict) + and item.get("uri") == "urn:veadk:mpa:resource-topology:v1" + ), + None, + ) + if not isinstance(topology, dict): + return unavailable + nodes, edges = topology.get("nodes"), topology.get("edges") + if not isinstance(nodes, list) or not isinstance(edges, list): + return unavailable + agents = { + node.get("id") + for node in nodes + if isinstance(node, dict) + and node.get("kind") == "agent" + and isinstance(node.get("id"), str) + } + targets = { + edge.get("target") + for edge in edges + if isinstance(edge, dict) + and isinstance(edge.get("source"), str) + and edge.get("source") in agents + and edge.get("relation") == "mounts" + and isinstance(edge.get("target"), str) + } + ids = dict.fromkeys( + node["resourceId"].strip() + for node in nodes + if isinstance(node, dict) + and isinstance(node.get("id"), str) + and node.get("id") in targets + and node.get("kind") == "skill-space" + and node.get("status") == "configured" + and isinstance(node.get("resourceId"), str) + and node["resourceId"].strip() + ) + return { + "skillSpacesStatus": "ready", + "skillSpaces": [{"id": space_id, "region": region} for space_id in ids], + } + + +async def load_mpa_agent_info( + endpoint: str, + headers: dict[str, str], + card: dict[str, Any] | None, + region: str, + *, + runtime_api_key: str = "", +) -> dict[str, Any]: + """Keep document failures independent from bound-space discovery.""" + result: dict[str, Any] = { + "agentsMd": None, + "agentsMdStatus": "error", + **_bound_spaces(card, region), + } + legacy_fallback = False + try: + async with httpx.AsyncClient(timeout=httpx.Timeout(10, connect=4)) as client: + safe_headers = { + key: value + for key, value in headers.items() + if key.lower() != "x-mpa-studio-key" + } + key = runtime_api_key.strip() + if key.lower().startswith("bearer "): + key = key[7:].strip() + if key: + response = await client.get( + f"{endpoint.rstrip('/')}/api/v1/studio/agent-info", + headers={**safe_headers, "X-MPA-Studio-Key": key}, + ) + if response.status_code in (404, 405): + legacy_fallback = True + response = await client.get( + f"{endpoint.rstrip('/')}/api/v1/agents", headers=safe_headers + ) + else: + response = await client.get( + f"{endpoint.rstrip('/')}/api/v1/agents", headers=safe_headers + ) + if response.status_code in (401, 403): + result["agentsMdStatus"] = "unsupported" if legacy_fallback else "forbidden" + elif response.status_code in (404, 405, 501): + result["agentsMdStatus"] = "unsupported" + elif response.is_success: + payload = response.json() + if not isinstance(payload, dict) or "agentsMd" not in payload: + return result + document = payload["agentsMd"] + if document is not None and not isinstance(document, str): + return result + result.update(agentsMd=document, agentsMdStatus="ready") + for key in ("name", "description", "model"): + if isinstance(payload.get(key), str): + result[key] = payload[key] + except (httpx.HTTPError, ValueError): + # Never forward arbitrary upstream bodies or transport credentials. + pass + return result diff --git a/frontend/server/mpa_creation.py b/frontend/server/mpa_creation.py new file mode 100644 index 000000000..c03f7ed80 --- /dev/null +++ b/frontend/server/mpa_creation.py @@ -0,0 +1,222 @@ +# 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. + +"""Authorized Studio entry points for VeADK-owned MPA creation.""" + +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any +from uuid import NAMESPACE_URL, UUID, uuid5 + +from fastapi import FastAPI, HTTPException, Request +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, + model_validator, +) + +from veadk.integrations.mpa.managed.config import ( + ConfigurationError, + load_studio_profile, + validate_creation_resources, + validate_image_reference, + validate_runtime_name, + validate_creation_tos, + with_creation_images, + with_creation_resources, + with_creation_tos, +) +from veadk.integrations.mpa.managed.credentials import load_volcengine_credentials +from veadk.integrations.mpa.managed.tasks import CreationTasks, TaskError, owner_key + + +def _studio_task_path() -> Path: + return Path( + os.getenv( + "VEADK_MPA_TASK_DB", + "/tmp/veadk-studio/mpa-creation.sqlite3", + ) + ) + + +class CreationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + requestId: UUID + agentId: str = Field(default="", pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$") + name: str = "" + description: str = Field(default="", max_length=512) + region: str = Field(pattern=r"^cn-[a-z]+$", max_length=32) + runtimeImage: str = Field(default="", max_length=1024) + workerImage: str = Field(default="", max_length=1024) + pgHost: str = Field(default="", max_length=255) + pgPort: str = Field(default="", max_length=5) + openvikingUrl: str = Field(default="", max_length=1024) + openvikingResourceId: str = Field(default="", max_length=128) + openvikingApiKey: str = Field(default="", max_length=512, repr=False) + tosAccessKey: str = Field(default="", max_length=256, repr=False) + tosSecretKey: str = Field(default="", max_length=256, repr=False) + tosBucket: str = Field(default="", max_length=256) + + @field_validator("runtimeImage", "workerImage") + @classmethod + def validate_image(cls, value): + return validate_image_reference(value) + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + return validate_runtime_name(value) + + @model_validator(mode="after") + def validate_resources(self): + if not self.name and not self.agentId: + raise ValueError("Runtime name is required") + validate_creation_resources(self.model_dump()) + validate_creation_tos(self.model_dump()) + return self + + +def mount_mpa_creation_routes( + app: FastAPI, *, owner, service: CreationTasks | None = None, supported=True +): + tasks = service + + def get_tasks(): + nonlocal tasks + if tasks is None: + task_path = _studio_task_path() + tasks = CreationTasks(task_path) + return tasks + + def profile(region): + if not supported: + raise ConfigurationError("MPA creation requires the Volcengine provider") + result = load_studio_profile(region=region) + try: + load_volcengine_credentials(result.managed.credential_file) + except ValueError: + raise ConfigurationError( + "Configure server deployment credentials" + ) from None + return None, result + + @app.get("/web/mpa-creation/config") + async def inspect(request: Request, region: str): + owner(request) + try: + _, result = profile(region) + return result.summary() + except ConfigurationError as exc: + return {"configured": False, "region": region, "error": str(exc)} + + @app.post("/web/mpa-creation/tasks", status_code=202) + async def create(request: Request): + studio_runtime_owner = owner(request) + identity = owner_key(studio_runtime_owner) + content = bytearray() + async for chunk in request.stream(): + content.extend(chunk) + if len(content) > 8192: + raise HTTPException(413, "Creation request is too large") + try: + body = CreationRequest.model_validate_json(bytes(content)) + except ValidationError: + raise HTTPException(422, "Invalid MPA creation request") from None + try: + path, config = profile(body.region) + payload = body.model_dump(mode="json") + if not payload["agentId"]: + payload["agentId"] = ( + "mi-" + + uuid5( + NAMESPACE_URL, f"veadk:mpa:{identity}:{body.requestId}" + ).hex[:24] + ) + if not payload["name"]: + # Keep the exact input shape of already-submitted legacy tasks. + payload.pop("name") + tos = { + field: payload.pop(field) + for field in ("tosAccessKey", "tosSecretKey", "tosBucket") + } + with_creation_resources(config, payload) + openviking_api_key = payload.pop("openvikingApiKey") + resources = validate_creation_resources( + {**payload, "openvikingApiKey": openviking_api_key} + ) + images = config.image_defaults() + for field in ("runtimeImage", "workerImage"): + if payload[field]: + images[field] = payload[field] + else: + payload.pop(field) + for field in ("pgHost", "pgPort", "openvikingUrl", "openvikingResourceId"): + if resources[field]: + payload[field] = resources[field] + else: + payload.pop(field) + with_creation_tos(with_creation_images(config, images), tos) + return await get_tasks().start( + identity, + payload, + config_path=path, + timeout=config.managed.timeout_seconds, + images=images, + secrets={"openvikingApiKey": openviking_api_key}, + studio_runtime_owner=studio_runtime_owner, + tos=tos, + ) + except ConfigurationError as exc: + raise HTTPException(400, str(exc)) from None + except TaskError as exc: + raise HTTPException(409, str(exc)) from None + + @app.get("/web/mpa-creation/tasks/{task_id}") + async def status(request: Request, task_id: str): + identity = owner_key(owner(request)) + try: + return get_tasks().get(identity, task_id) + except TaskError: + raise HTTPException(404, "Creation task not found") from None + + @app.post("/web/mpa-creation/tasks/{task_id}/cancel") + async def cancel(request: Request, task_id: str): + identity = owner_key(owner(request)) + try: + return await get_tasks().cancel(identity, task_id) + except TaskError: + raise HTTPException(404, "Creation task not found") from None + + async def shutdown(): + if tasks is not None: + await tasks.close() + + previous_lifespan = app.router.lifespan_context + + @asynccontextmanager + async def lifespan(instance: FastAPI) -> AsyncIterator[dict[str, Any]]: + try: + async with previous_lifespan(instance) as state: + yield dict(state or {}) + finally: + await shutdown() + + app.router.lifespan_context = lifespan diff --git a/frontend/server/mpa_cron.py b/frontend/server/mpa_cron.py new file mode 100644 index 000000000..7e6a7eb2c --- /dev/null +++ b/frontend/server/mpa_cron.py @@ -0,0 +1,196 @@ +# 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. + +"""User-scoped task management through authorized MPA Runtime connections.""" + +import asyncio +from collections.abc import Callable +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException, Path, Query, Request + + +async def task_request( + endpoint: str, + authorization: str, + user_id: str, + method: str, + suffix: str = "", + params: dict | None = None, + payload: dict | None = None, +) -> dict[str, Any]: + if not user_id: + raise HTTPException(401, "mpa_identity_required") + try: + async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client: + response = await client.request( + method, + endpoint.rstrip("/") + "/api/v1/esa-cron-tasks" + suffix, + headers={"Authorization": authorization, "x-user-id": user_id}, + params=params, + json=payload, + ) + if not response.is_success: + raise HTTPException( + response.status_code if response.is_error else 502, + "mpa_tasks_failed", + ) + data = response.json() + if not isinstance(data, dict): + raise TypeError("Invalid task response") + return data + except (httpx.HTTPError, ValueError, TypeError) as error: + raise HTTPException(502, "mpa_upstream_failed") from error + + +def mount_routes( + app: FastAPI, + *, + authorize: Callable, + connection: Callable, + authorization: Callable, + region_for: Callable, + user_for: Callable, +) -> None: + async def send( + request, runtime_id, region, method, suffix="", params=None, payload=None + ): + region = region_for(region) + runtime = await asyncio.to_thread(authorize, request, runtime_id, region) + user_id = user_for(request) + endpoint, apikey, auth_type, _ = await asyncio.to_thread( + connection, runtime_id, region, runtime + ) + return await task_request( + endpoint, + authorization(request, apikey, auth_type), + user_id, + method, + suffix, + params, + payload, + ) + + async def body(request: Request, allowed: set[str]): + raw = await request.body() + if len(raw) > 32768: + raise HTTPException(413, "mpa_payload_too_large") + try: + payload = await request.json() + except ValueError as error: + raise HTTPException(422, "mpa_invalid_payload") from error + if not isinstance(payload, dict) or set(payload) - allowed: + raise HTTPException(422, "mpa_invalid_payload") + return payload + + fields = { + "name", + "agentId", + "prompt", + "enabled", + "schedule", + "delivery", + "jitterSeconds", + "timeoutSeconds", + } + + @app.get("/web/mpa-cron/{runtime_id}") + async def get_tasks( + request: Request, + runtime_id: str, + region: str, + offset: int = Query(0, ge=0), + query: str = Query("", max_length=200), + ): + return await send( + request, + runtime_id, + region, + "GET", + params={ + "includeDisabled": "true", + "limit": 20, + "offset": offset, + "query": query, + }, + ) + + @app.post("/web/mpa-cron/{runtime_id}") + async def create_task(request: Request, runtime_id: str, region: str): + return await send( + request, + runtime_id, + region, + "POST", + payload=await body(request, fields | {"clientToken"}), + ) + + @app.post("/web/mpa-cron/{runtime_id}/{task_id}") + async def update_task( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + ): + return await send( + request, + runtime_id, + region, + "POST", + "/" + task_id, + payload=await body(request, fields | {"expectedVersion"}), + ) + + @app.delete("/web/mpa-cron/{runtime_id}/{task_id}") + async def delete_task( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + ): + return await send(request, runtime_id, region, "DELETE", "/" + task_id) + + @app.post("/web/mpa-cron/{runtime_id}/{task_id}/run") + async def run_task( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + ): + return await send( + request, + runtime_id, + region, + "POST", + "/" + task_id + "/run", + payload=await body(request, {"clientToken", "mode"}), + ) + + @app.get("/web/mpa-cron/{runtime_id}/{task_id}/runs") + async def get_runs( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + offset: int = Query(0, ge=0), + ): + return await send( + request, + runtime_id, + region, + "GET", + "/" + task_id + "/runs", + params={"offset": offset, "limit": 20}, + ) diff --git a/frontend/server/mpa_identity_callback.py b/frontend/server/mpa_identity_callback.py new file mode 100644 index 000000000..b7abeb5c3 --- /dev/null +++ b/frontend/server/mpa_identity_callback.py @@ -0,0 +1,438 @@ +# 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. + +"""Server-side OAuth callback relay for AgentKit MPA runtimes.""" + +from __future__ import annotations + +import base64 +import ipaddress +import json +import logging +import re +from collections.abc import Awaitable, Callable, Iterable +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode, urlsplit, urlunsplit + +import httpx +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response + +logger = logging.getLogger(__name__) + +MPA_CALLBACK_PATH = "/oauth/callback" +MPA_RUNTIME_CALLBACK_PATH = "/identity/oauth/callback" +MAX_STATE_LENGTH = 16 * 1024 +MAX_CALLBACK_RESPONSE_BYTES = 64 * 1024 +CALLBACK_TIMEOUT_SECONDS = 10.0 + +_SECURITY_HEADERS = { + "Cache-Control": "private, no-store, max-age=0", + "Content-Security-Policy": ( + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; " + "form-action 'none'; frame-ancestors 'none'" + ), + "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Pragma": "no-cache", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", +} + + +@dataclass(frozen=True) +class MpaCallbackTarget: + instance_id: str + + +@dataclass(frozen=True) +class IdentityRelayState: + request_id: str + provider_id: str + target: MpaCallbackTarget + + +@dataclass(frozen=True) +class MpaRuntimeCredentials: + endpoint_origin: str + api_key: str + + +@dataclass(frozen=True) +class RuntimeCallbackResult: + http_status: int + payload: dict[str, Any] + + +HostedCallbackResolver = Callable[[str], Awaitable[str]] +RuntimeCredentialsResolver = Callable[ + [MpaCallbackTarget], Awaitable[MpaRuntimeCredentials] +] + + +class MpaIdentityCallbackError(ValueError): + """A sanitized callback error that is safe to expose through logs.""" + + +def select_mpa_runtime( + candidates: Iterable[tuple[str, Any]], + target: MpaCallbackTarget, +) -> tuple[str, Any]: + """Select exactly one Runtime by trusted control-plane environment metadata.""" + matches: list[tuple[str, Any]] = [] + for region, runtime in candidates: + envs = { + str(getattr(item, "key", "") or ""): str(getattr(item, "value", "") or "") + for item in (getattr(runtime, "envs", None) or []) + } + if envs.get("MPA_AGENT_ID", "").strip() == target.instance_id: + matches.append((region, runtime)) + if len(matches) != 1: + reason = "not found" if not matches else "not unique" + raise MpaIdentityCallbackError(f"MPA Runtime {reason}") + return matches[0] + + +def _decode_base64url_json(value: str) -> dict[str, Any] | None: + if not value or len(value) > MAX_STATE_LENGTH: + return None + try: + padding = "=" * (-len(value) % 4) + decoded = base64.urlsafe_b64decode(f"{value}{padding}") + payload = json.loads(decoded.decode("utf-8")) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def parse_identity_relay_state(value: str) -> IdentityRelayState | None: + """Decode the outer UserPool relay state and its strict MPA target.""" + outer = _decode_base64url_json(value.strip()) + if outer is None: + return None + request_id = outer.get("request_id") + provider_id = outer.get("provider_id") + request_state = outer.get("request_state") + if ( + not isinstance(request_id, str) + or not request_id.strip() + or not isinstance(provider_id, str) + or not provider_id.strip() + or not isinstance(request_state, str) + or not request_state.strip() + ): + return None + + target = parse_mpa_runtime_state(request_state) + if target is None: + return None + return IdentityRelayState( + request_id=request_id.strip(), + provider_id=provider_id.strip(), + target=target, + ) + + +def parse_mpa_runtime_state(value: str) -> MpaCallbackTarget | None: + """Decode the UserPool-issued callback state and its strict MPA target.""" + payload = _decode_base64url_json(value.strip()) + if payload is None: + return None + target = payload.get("target") + if ( + not isinstance(target, str) + or not target.startswith("mi-") + or len(target) <= len("mi-") + ): + return None + return MpaCallbackTarget(target.strip()) + + +def _normalize_https_origin(value: str) -> str: + try: + parsed = urlsplit(value.strip()) + hostname = (parsed.hostname or "").rstrip(".").lower() + port = parsed.port + except ValueError: + raise ValueError("invalid HTTPS origin") from None + try: + ipaddress.ip_address(hostname) + except ValueError: + is_ip = False + else: + is_ip = True + if ( + parsed.scheme != "https" + or not hostname + or is_ip + or parsed.username + or parsed.password + or port + or len(hostname) > 253 + or any( + re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) is None + for label in hostname.split(".") + ) + or "." not in hostname + ): + raise ValueError("invalid HTTPS origin") + return urlunsplit(("https", hostname, "", "", "")) + + +def _normalize_runtime_endpoint(value: str) -> str: + parsed = urlsplit(value) + origin = _normalize_https_origin(value) + if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: + raise MpaIdentityCallbackError("invalid Runtime endpoint") + return origin + + +def build_user_pool_hosted_callback_url( + issuer: str, + connection_type: str, +) -> str: + """Build the trusted UserPool relay endpoint from discovered pool metadata.""" + origin = _normalize_https_origin(issuer) + callback_kind = { + "oauth": "generic_oauth", + "oidc": "generic_oidc", + }.get(connection_type.strip().lower()) + if callback_kind is None: + raise ValueError("unsupported identity provider connection type") + return f"{origin}/login/{callback_kind}/callback" + + +def _validate_hosted_callback_url(value: str) -> str: + parsed = urlsplit(value) + origin = _normalize_https_origin(value) + if ( + parsed.path + not in { + "/login/generic_oauth/callback", + "/login/generic_oidc/callback", + } + or parsed.query + or parsed.fragment + ): + raise MpaIdentityCallbackError("invalid UserPool callback endpoint") + return f"{origin}{parsed.path}" + + +def _user_pool_redirect_response( + hosted_callback_url: str, + code: str, + state: str, +) -> RedirectResponse: + callback_url = _validate_hosted_callback_url(hosted_callback_url) + return RedirectResponse( + f"{callback_url}?{urlencode({'code': code, 'state': state})}", + status_code=302, + headers=_SECURITY_HEADERS, + ) + + +def _parse_runtime_payload(response: httpx.Response) -> dict[str, Any]: + content_length = response.headers.get("content-length") + if content_length: + try: + if int(content_length) > MAX_CALLBACK_RESPONSE_BYTES: + raise MpaIdentityCallbackError("Runtime callback response is too large") + except ValueError: + pass + if len(response.content) > MAX_CALLBACK_RESPONSE_BYTES: + raise MpaIdentityCallbackError("Runtime callback response is too large") + try: + payload = response.json() + except ValueError as error: + raise MpaIdentityCallbackError( + "Runtime returned invalid callback JSON" + ) from error + code = payload.get("code") if isinstance(payload, dict) else None + if ( + not isinstance(payload, dict) + or not isinstance(code, int) + or isinstance(code, bool) + or "message" not in payload + or "error" not in payload + ): + raise MpaIdentityCallbackError("Runtime returned invalid callback payload") + return payload + + +async def _call_runtime_callback( + client: httpx.AsyncClient, + credentials: MpaRuntimeCredentials, + code: str, + state: str, +) -> RuntimeCallbackResult: + origin = _normalize_runtime_endpoint(credentials.endpoint_origin) + api_key = credentials.api_key.strip() + if not api_key: + raise MpaIdentityCallbackError("Runtime key authentication is unavailable") + response = await client.get( + f"{origin}{MPA_RUNTIME_CALLBACK_PATH}", + params={"code": code, "state": state}, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + }, + follow_redirects=False, + ) + if 300 <= response.status_code < 400: + raise MpaIdentityCallbackError("Runtime callback redirected unexpectedly") + return RuntimeCallbackResult(response.status_code, _parse_runtime_payload(response)) + + +def _result_page(title: str, heading: str, description: str) -> str: + return f""" + + + + + {title} + + +

{heading}

{description}

+""" + + +def _invalid_request() -> JSONResponse: + return JSONResponse( + {"code": 4002, "message": "", "error": "Invalid callback request"}, + status_code=400, + headers=_SECURITY_HEADERS, + ) + + +def _internal_error() -> JSONResponse: + return JSONResponse( + {"code": 5000, "message": "", "error": "MPA authorization failed"}, + status_code=502, + headers=_SECURITY_HEADERS, + ) + + +def _runtime_result_response(result: RuntimeCallbackResult) -> Response: + code = result.payload["code"] + if code == 0: + return HTMLResponse( + _result_page("授权成功", "授权成功", "您可以关闭本页面并返回会话。"), + status_code=200, + headers=_SECURITY_HEADERS, + ) + if code == 4003: + return HTMLResponse( + _result_page( + "授权链接已失效", + "授权链接已失效", + "请返回会话重新触发授权。", + ), + status_code=result.http_status, + headers=_SECURITY_HEADERS, + ) + return JSONResponse( + result.payload, + status_code=result.http_status, + headers=_SECURITY_HEADERS, + ) + + +def mount_mpa_identity_callback( + app: FastAPI, + *, + hosted_callback_resolver: HostedCallbackResolver, + runtime_credentials_resolver: RuntimeCredentialsResolver, + http_client: httpx.AsyncClient | None = None, +) -> None: + """Mount the public browser callback for MPA Runtime authorization.""" + + @app.get(MPA_CALLBACK_PATH) + async def mpa_identity_callback( + code: str | None = None, + state: str | None = None, + error: str | None = None, + ) -> Response: + if error or not code or not state: + return _invalid_request() + relay_state = parse_identity_relay_state(state) + if relay_state is not None: + stage = "resolve_userpool_callback" + try: + hosted_callback_url = await hosted_callback_resolver( + relay_state.provider_id + ) + return _user_pool_redirect_response(hosted_callback_url, code, state) + except Exception as callback_error: # noqa: BLE001 - sanitize route boundary + safe_error = ( + str(callback_error) + if isinstance(callback_error, MpaIdentityCallbackError) + else type(callback_error).__name__ + ) + logger.warning( + "MPA identity callback failed stage=%s error=%s " + "error_type=%s request_id=%s target=%s", + stage, + safe_error, + type(callback_error).__name__, + relay_state.request_id, + relay_state.target.instance_id, + ) + return _internal_error() + + target = parse_mpa_runtime_state(state) + if target is None: + return _invalid_request() + + owned_client = http_client is None + client = http_client or httpx.AsyncClient( + timeout=httpx.Timeout(CALLBACK_TIMEOUT_SECONDS), + follow_redirects=False, + ) + stage = "resolve_runtime" + try: + credentials = await runtime_credentials_resolver(target) + stage = "runtime_callback" + result = await _call_runtime_callback( + client, + credentials, + code, + state, + ) + response = _runtime_result_response(result) + except Exception as callback_error: # noqa: BLE001 - sanitize route boundary + safe_error = ( + str(callback_error) + if isinstance(callback_error, MpaIdentityCallbackError) + else type(callback_error).__name__ + ) + logger.warning( + "MPA identity callback failed stage=%s error=%s " + "error_type=%s target=%s", + stage, + safe_error, + type(callback_error).__name__, + target.instance_id, + ) + response = _internal_error() + finally: + if owned_client: + await client.aclose() + + return response diff --git a/frontend/server/runtime_logs.py b/frontend/server/runtime_logs.py index ec3ce73dd..db53457b7 100644 --- a/frontend/server/runtime_logs.py +++ b/frontend/server/runtime_logs.py @@ -277,10 +277,11 @@ async def read_logs( runtime_types.GetRuntimeInstanceLogsRequest( RuntimeId=runtime_id, InstanceName=instance_name, - Limit=500, + Limit=1000, ), ) - return self._sanitize(str(response.logs or "")) + sanitized = self._sanitize(str(response.logs or "")) + return "\n".join(sanitized.splitlines()[-1000:]) async def snapshot( self, diff --git a/frontend/server/skills/documents.py b/frontend/server/skills/documents.py new file mode 100644 index 000000000..8a811958b --- /dev/null +++ b/frontend/server/skills/documents.py @@ -0,0 +1,59 @@ +# 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. + +"""Edit only SKILL.md while preserving every other archive member.""" + +import io +import zipfile +from pathlib import PurePosixPath + +from .archive import validate_skill_archive +from .repository import SkillRepositoryError + + +def replace_skill_document(content: bytes, document: str) -> bytes: + original = validate_skill_archive(content) + output = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(content)) as source: + names = [ + name + for name in source.namelist() + if PurePosixPath(name).parts[0] != "__MACOSX" + ] + path = next( + (name for name in names if PurePosixPath(name).as_posix() == "SKILL.md"), + None, + ) + if path is None: + path = next( + name + for name in names + if len(PurePosixPath(name).parts) == 2 + and PurePosixPath(name).name == "SKILL.md" + ) + with zipfile.ZipFile(output, "w") as target: + for info in source.infolist(): + target.writestr( + info, + document.encode("utf-8") + if info.filename == path + else source.read(info), + ) + result = output.getvalue() + updated = validate_skill_archive(result) + if updated.name != original.name: + raise SkillRepositoryError( + "SKILL_VERSION_NAME_MISMATCH", "新版本必须保留原技能名称", status_code=422 + ) + return result diff --git a/frontend/server/skills/version_routes.py b/frontend/server/skills/version_routes.py index 87c67eadd..a50a8082b 100644 --- a/frontend/server/skills/version_routes.py +++ b/frontend/server/skills/version_routes.py @@ -20,11 +20,17 @@ from typing import Any from fastapi import Query, Request +from pydantic import BaseModel, Field from .models import SkillIdentity from .versions import SkillVersionRepository +class SkillDocumentBody(BaseModel): + baseVersion: str = Field(min_length=1, max_length=64) + content: str = Field(min_length=1, max_length=262144) + + def mount_skill_version_routes( app: Any, versions: SkillVersionRepository, @@ -32,6 +38,40 @@ def mount_skill_version_routes( invoke: Callable[[Callable[[], Any]], Awaitable[Any]], read_archive: Callable[[Request], Awaitable[bytes]], ) -> None: + @app.get("/web/skill-management/spaces/{space_id}/skills/{skill_id}/document") + async def get_document( + request: Request, + space_id: str, + skill_id: str, + region: str = Query(..., min_length=1, max_length=64), + ) -> Any: + identity = identity_resolver(request) + return await invoke( + lambda: versions.document( + identity, region=region, space_id=space_id, skill_id=skill_id + ) + ) + + @app.put("/web/skill-management/spaces/{space_id}/skills/{skill_id}/document") + async def put_document( + request: Request, + space_id: str, + skill_id: str, + body: SkillDocumentBody, + region: str = Query(..., min_length=1, max_length=64), + ) -> Any: + identity = identity_resolver(request) + return await invoke( + lambda: versions.save_document( + identity, + region=region, + space_id=space_id, + skill_id=skill_id, + base_version=body.baseVersion, + document=body.content, + ) + ) + @app.get("/web/skill-management/spaces/{space_id}/skills/{skill_id}/versions") async def list_versions( request: Request, diff --git a/frontend/server/skills/versions.py b/frontend/server/skills/versions.py index cfa8fc148..757894476 100644 --- a/frontend/server/skills/versions.py +++ b/frontend/server/skills/versions.py @@ -189,6 +189,7 @@ def upload( space_id: str, skill_id: str, content: bytes, + expected_version: str | None = None, ) -> dict[str, Any]: from agentkit.sdk.skills import types as sdk from agentkit.toolkit.cli.cli_skills_workflow import ( @@ -220,6 +221,14 @@ def upload( status_code=422, ) previous = {item.version for item in self._versions(client, skill_id)} + if expected_version is not None and ( + not previous or max(previous, key=version_order) != expected_version + ): + raise SkillRepositoryError( + "SKILL_DOCUMENT_CONFLICT", + "技能已有新版本,请重新打开后编辑。", + status_code=409, + ) config = GlobalConfigManager().load() storage = resolve_skill_publish_storage( region=region, @@ -263,3 +272,66 @@ def upload( "description": archive.description, "skillSpaceId": space_id, } + + def document( + self, identity: SkillIdentity, *, region: str, space_id: str, skill_id: str + ) -> dict[str, Any]: + versions = self.list( + identity, region=region, space_id=space_id, skill_id=skill_id + ) + if not versions["items"]: + raise SkillRepositoryError( + "SKILL_DOCUMENT_NOT_FOUND", "未找到技能版本", status_code=404 + ) + version = versions["items"][0]["version"] + content, _ = self._repository.skill_archive( + region=region, + space_id=space_id, + skill_id=skill_id, + version=version, + ) + return { + "content": validate_skill_archive(content).skill_md, + "baseVersion": version, + "canUpdate": versions["canUpdate"], + } + + def save_document( + self, + identity: SkillIdentity, + *, + region: str, + space_id: str, + skill_id: str, + base_version: str, + document: str, + ) -> dict[str, Any]: + from .documents import replace_skill_document + + versions = self.list( + identity, region=region, space_id=space_id, skill_id=skill_id + ) + if not versions["canUpdate"]: + raise SkillRepositoryError( + "SKILL_VERSION_UPDATE_FORBIDDEN", "无权修改此技能", status_code=403 + ) + if not versions["items"] or versions["items"][0]["version"] != base_version: + raise SkillRepositoryError( + "SKILL_DOCUMENT_CONFLICT", + "技能已有新版本,请重新打开后编辑。", + status_code=409, + ) + content, _ = self._repository.skill_archive( + region=region, + space_id=space_id, + skill_id=skill_id, + version=base_version, + ) + return self.upload( + identity, + region=region, + space_id=space_id, + skill_id=skill_id, + content=replace_skill_document(content, document), + expected_version=base_version, + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1d5655d6f..921b6aa13 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,10 +3,12 @@ import { useDevelopmentRun } from "./create/useDevelopmentRun"; import { developmentRunStatus } from "./create/developmentPresentation"; import { DevelopmentTaskNotice, type DevelopmentTaskSnapshot } from "./create/DevelopmentTaskNotice"; import { UserManagement } from "./users/UserManagement"; +import { SandboxFileContext } from "./ui/SandboxFileLink"; import { useCallback, useEffect, useLayoutEffect, + useMemo, useRef, useState, type MouseEventHandler, @@ -34,21 +36,29 @@ import { downloadArtifact, previewArtifact, getAgentInfo, + getTurnControl, getAutomaticEvaluationStatuses, + getMpaSessionExecutionConfig, + getMpaAgentOperation, getSessionTrace, getSession, getStudioAccess, getRuntimeStudioToolCapabilities, getRuntimeMcpCredentials, getRuntimes, + isMpaRuntimeApp, + isMpaA2aRuntimeApp, listApps, listEnvironments, listWorkspaces, listModelOptions, + listActiveMpaAgentOperations, listSessions, prepareSessionEnvironmentMounts, runSseIncompleteResponseError, runSSE, + controlTurn, + continueTurnSSE, refreshAgentFeedbackCases, submitIssueFeedback, submitMessageFeedback, @@ -66,16 +76,21 @@ import { type FrontendInvocation, type CloudRuntime, type MessageFeedbackRating, + type MpaSessionExecutionConfig, + type MpaAgentOperation, type SiteBranding, type RuntimeStudioToolCapabilities, type SessionEnvironmentMountSelection, type StudioAccess, type StudioEnvironment, type StudioWorkspace, + type TurnControlState, + TurnControlConflictError, type UiConfig, type UiFeatures, } from "./adk/client"; import type { RuntimeLogTarget } from "./adk/runtimeLogs"; +import type { SelectedSkill } from "./create/skills/types"; import { addTokenUsage, aggregateTokenUsage, @@ -93,20 +108,22 @@ import { createAssistantEventProjector, eventsToTurns, sessionTitle, + shouldShowEmptyAssistantResponse, upsertProjectedAssistantTurn, type Block, type IntelligentDevelopmentReleaseRef, type Turn, type TurnActivityDetail, } from "./blocks"; +import { reconcilePersistedTranscript } from "./transcriptReconcile"; import { i18n } from "./i18n"; -import { buildTranscriptRows } from "./transcriptRows"; +import { buildTranscriptRows, groupMpaTranscriptTurns } from "./transcriptRows"; import { Sidebar, type SidebarPage } from "./ui/Sidebar"; -import { AgentInfoPanel } from "./ui/AgentTopology"; +import { MpaAgentInfoRail } from "./ui/mpa-agent-info/MpaAgentInfoRail"; import type { SkillCenterWorkspaceLaunch } from "./ui/SkillCenter"; import { LibraryView, type LibraryTab } from "./ui/LibraryView"; import { AddAgentKitView } from "./ui/AddAgentKit"; -import { AgentWorkspace } from "./ui/AgentWorkspace"; +import { AgentWorkspace, type MpaProfileEditTarget } from "./ui/AgentWorkspace"; import { MyAgents, invalidateRuntimeAgentCache, @@ -124,6 +141,8 @@ import { GitHubIntegration } from "./ui/GitHubIntegration"; import { GitLabIntegration } from "./ui/GitLabIntegration"; import { FeishuBotIntegration } from "./automations/feishu/FeishuBotIntegration"; import { CodingAgentsIntegration } from "./automations/coding-agents/CodingAgentsIntegration"; +import type { AutomationCategoryId } from "./automations/types"; +import { MpaChannelsAutomation } from "./automations/mpa-channels/MpaChannelsAutomation"; import { WebsiteIntegration } from "./automations/website-integration/WebsiteIntegration"; import { SearchView } from "./ui/Search"; import { @@ -310,6 +329,14 @@ function issueFeedbackModuleForPage(page: string): IssueFeedbackModule { return "other"; } +function isOptionalStudioStorageUnavailable(cause: unknown): boolean { + const message = cause instanceof Error ? cause.message : String(cause ?? ""); + return message.includes("HTTP 503") && ( + message.includes("未配置持久化存储") || + message.includes("storage is not configured") + ); +} + interface NewChatCapabilitiesState { agentId?: string; ready?: boolean; @@ -358,6 +385,7 @@ async function loadHydratedSessions( userId: string, ): Promise { const list = await listSessions(appName, userId); + if (isMpaRuntimeApp(appName)) return list; const results = await Promise.allSettled( list.map((session) => session.events?.length @@ -417,8 +445,6 @@ const DRAFT_AUTOSAVE_DELAY_MS = 600; const AUTO_EVALUATION_RUNNING_POLL_MS = 1_000; const AUTO_EVALUATION_RETRY_POLL_MS = 5_000; const AUTO_EVALUATION_MIN_PENDING_POLL_MS = 500; -const EMPTY_STRING_SET: Set = new Set(); -const EMPTY_STRING_ARR: string[] = []; const ENVIRONMENT_STUDIO_TOOL_IDS = [ "list_envs", "get_env_manifest", @@ -426,6 +452,7 @@ const ENVIRONMENT_STUDIO_TOOL_IDS = [ "delegate_to_codex_sandbox", ] as const; const SESSION_ENVIRONMENT_STORAGE_KEY = "veadk.sessionEnvironmentMounts.v1"; +const SESSION_SKILL_STORAGE_KEY = "veadk.sessionSkillMounts.v1"; interface StoredSessionEnvironmentState { mounts: Record; @@ -501,6 +528,43 @@ function persistSessionEnvironmentState( } } +function loadStoredSessionSkills(): Record { + if (typeof localStorage === "undefined") return {}; + try { + const raw = JSON.parse(localStorage.getItem(SESSION_SKILL_STORAGE_KEY) ?? "{}"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record).slice(-200).map(([key, value]) => [ + key, + Array.isArray(value) + ? value.slice(0, 20).filter((item): item is SelectedSkill => ( + Boolean(item) + && typeof item === "object" + && !Array.isArray(item) + && (item as SelectedSkill).source === "skillspace" + && typeof (item as SelectedSkill).name === "string" + && typeof (item as SelectedSkill).folder === "string" + && typeof (item as SelectedSkill).skillSpaceId === "string" + && typeof (item as SelectedSkill).skillId === "string" + && typeof (item as SelectedSkill).version === "string" + )) + : [], + ]), + ); + } catch { + return {}; + } +} + +function persistSessionSkills(skills: Record) { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(SESSION_SKILL_STORAGE_KEY, JSON.stringify(skills)); + } catch { + // Storage can be unavailable in private or quota-restricted browsers. + } +} + function emptyInvocation(): FrontendInvocation { return { skills: [] }; } @@ -967,6 +1031,10 @@ function appText(key: string, options?: Record): string { return i18n.t(key, { ns: "app", ...options }); } +function uiText(key: string, options?: Record): string { + return i18n.t(key, { ns: "ui", ...options }); +} + function releaseAttachmentPreviews(items: Attachment[]) { for (const item of items) { if (item.previewUrl?.startsWith("blob:")) URL.revokeObjectURL(item.previewUrl); @@ -1117,12 +1185,26 @@ function sessionUsageKey(app: string, session: string): string { return `${app}\u0001${session}`; } +function lastCompletedEventId(turns: Turn[] | undefined): string | undefined { + const value = [...(turns ?? [])] + .reverse() + .find((turn) => turn.role === "assistant" && turn.meta?.eventId) + ?.meta?.eventId; + return value?.trim() || undefined; +} + export default function App() { const { t } = useTranslation("app"); const [apps, setApps] = useState([]); - const [appName, setAppName] = useState(""); + const [appName, setAppName] = useState(() => + typeof localStorage !== "undefined" ? localStorage.getItem(LS.app) || "" : "", + ); const [sessions, setSessions] = useState([]); - const [sessionId, setSessionId] = useState(""); + const [sessionId, setSessionId] = useState(() => + typeof localStorage !== "undefined" + ? localStorage.getItem(LS.session) || "" + : "", + ); const creatingSessionRef = useRef | null>(null); const sessionRefreshRequestRef = useRef(0); const agentSelectionPreparationRequestRef = useRef(0); @@ -1455,17 +1537,21 @@ export default function App() { const [studioToolCapabilities, setStudioToolCapabilities] = useState(null); const [studioToolsLoading, setStudioToolsLoading] = useState(false); - const [studioToolsError, setStudioToolsError] = useState(""); + const [selectedCronRuntime, setSelectedCronRuntime] = useState<{ + runtimeId: string; name: string; region: string; + } | undefined>(); + useEffect(() => setSelectedCronRuntime(undefined), [appName]); const [draftStudioRuntime, setDraftStudioRuntime] = useState<{ appName: string; runtimeId: string; + mpaInstanceId?: string; name: string; region: string; } | null>(null); - const [draftStudioToolIds, setDraftStudioToolIds] = useState([]); - const [studioToolIdsBySession, setStudioToolIdsBySession] = useState< - Record - >({}); + const [sessionSkillsBySession] = useState< + Record + >(() => loadStoredSessionSkills()); + useEffect(() => persistSessionSkills(sessionSkillsBySession), [sessionSkillsBySession]); const [sessionEnvironments, setSessionEnvironments] = useState([]); const [sessionWorkspaces, setSessionWorkspaces] = useState([]); const [sessionEnvironmentsLoading, setSessionEnvironmentsLoading] = useState(false); @@ -1493,7 +1579,14 @@ export default function App() { Record >({}); const [agentInfo, setAgentInfo] = useState(null); + const [turnControlBySession, setTurnControlBySession] = useState>({}); + const turnControlBySessionRef = useRef>({}); + const [turnControlBusy, setTurnControlBusy] = useState(false); + const [selectedModelBySession, setSelectedModelBySession] = useState< + Record + >({}); const [agentInfoRefreshKey, setAgentInfoRefreshKey] = useState(0); + const agentInfoSelectionRef = useRef(""); const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); const removedAttachmentIdsRef = useRef>(new Set()); // Streaming state is PER SESSION so multiple sessions can stream at once @@ -1838,20 +1931,6 @@ export default function App() { const [uiConfigLoaded, setUiConfigLoaded] = useState(false); const [localMode, setLocalMode] = useState(false); const [loadingSession, setLoadingSession] = useState(false); - // The executing sub-agent (ADK event.author) and everyone who emitted this - // turn — PER SESSION, so each session's topology highlights its own stream. - const [activeAgentBySession, setActiveAgentBySession] = useState< - Record - >({}); - const [seenAgentsBySession, setSeenAgentsBySession] = useState< - Record> - >({}); - // The current delegation chain (root → … → executing agent) per session, - // built from event.actions.transfer_to_agent / end_of_agent. - const [execPathBySession, setExecPathBySession] = useState< - Record - >({}); - // Everything the view needs for the ACTIVE session, derived from the // per-session maps above. const busy = streamingSids.has(sessionId); @@ -1985,9 +2064,6 @@ export default function App() { 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; const rootCapabilityNode = agentInfo?.graph; const rootAgentNames = [ agentInfo?.name, @@ -2010,11 +2086,13 @@ export default function App() { tools: [ ...new Set([ ...(rootCapabilityNode?.tools ?? agentInfo.tools), - ...(sessionId - ? (studioToolIdsBySession[ - studioToolSelectionKey(appName, userId, sessionId) - ] ?? []) - : draftStudioToolIds), + ...(sessionId && ( + environmentMountsBySession[ + studioToolSelectionKey(appName, userId, sessionId) + ] ?? [] + ).length > 0 + ? [...ENVIRONMENT_STUDIO_TOOL_IDS] + : []), ]), ], skills: rootCapabilityNode?.skills ?? agentInfo.skills, @@ -2067,41 +2145,6 @@ export default function App() { } } - // Apply a stream event's control-flow signals to a session's live state: - // author = who's executing now; transfer_to_agent pushes the delegation - // chain; end_of_agent / escalate pops it. `author` always wins for highlight. - const applyStreamSignals = (sid: string, ev: AdkEvent) => { - const who = ev.author && ev.author !== "user" ? ev.author : undefined; - if (who) { - setActiveAgentBySession((m) => ({ ...m, [sid]: who })); - setSeenAgentsBySession((m) => ({ - ...m, - [sid]: new Set(m[sid] ?? []).add(who), - })); - // Seed the path with the entry (root) agent on the first event. - setExecPathBySession((m) => - m[sid]?.length ? m : { ...m, [sid]: [who] }, - ); - } - const transferTo = - ev.actions?.transferToAgent ?? ev.actions?.transfer_to_agent; - if (transferTo) { - setExecPathBySession((m) => { - const cur = m[sid] ?? []; - return cur[cur.length - 1] === transferTo - ? m - : { ...m, [sid]: [...cur, transferTo] }; - }); - } - const ended = - ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate; - if (ended) { - setExecPathBySession((m) => { - const cur = m[sid] ?? []; - return cur.length <= 1 ? m : { ...m, [sid]: cur.slice(0, -1) }; - }); - } - }; const [createView, setCreateView] = useState(loadView); const [deepseekDraft, setDeepseekDraft] = useState(createNativeDraft); const [deploymentTasks, setDeploymentTasks] = useState< @@ -2119,6 +2162,38 @@ export default function App() { return next; }); }, []); + + const operationToDeploymentTask = useCallback(( + operation: MpaAgentOperation, + ): DeploymentTaskUpdate => { + const succeeded = operation.status === "succeeded"; + const failed = operation.status === "failed_retryable" + || operation.status === "failed_terminal"; + const startedAt = Date.parse(operation.createdAt ?? operation.updatedAt ?? ""); + return { + id: `mpa-operation:${operation.operationId}`, + agentName: operation.mpaInstanceId || operation.runtimeId || operation.operationId, + runtimeName: operation.mpaInstanceId || operation.runtimeId || operation.operationId, + runtimeId: operation.runtimeId, + mpaInstanceId: operation.mpaInstanceId, + region: operation.runtimeRegion || "cn-beijing", + startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(), + status: succeeded ? "success" : failed ? "error" : "running", + phase: operation.stage, + label: failed + ? uiText("agentWorkspace.deploymentFailed") + : succeeded + ? uiText("agentWorkspace.deployStatus.success") + : uiText("agentWorkspace.deployStatus.running"), + message: operation.safeErrorCode || undefined, + pct: succeeded || failed ? 100 : undefined, + mpaProfile: true, + mpaSmoke: operation.operationKind === "create", + operationId: operation.operationId, + }; + }, []); + const recoveredOperationIdsRef = useRef(new Set()); + // Whether the server has cloud AK/SK. The agent-creation workbench needs // them; assume present until the runtime-config check says otherwise (avoids // flashing the notice in the common, configured case). @@ -2169,7 +2244,7 @@ export default function App() { const [feedbackCaseReturnKind, setFeedbackCaseReturnKind] = useState<"good" | "bad">("good"); const [focusedWorkspaceAgentSection, setFocusedWorkspaceAgentSection] = - useState<"basic" | "evaluations">("basic"); + useState<"basic" | "sessionConfig" | "evaluations">("basic"); const [focusedWorkspaceCaseKind, setFocusedWorkspaceCaseKind] = useState<"good" | "bad">("good"); const [feedbackTargetEventId, setFeedbackTargetEventId] = useState(""); @@ -2200,6 +2275,7 @@ export default function App() { return current.filter((_, entryIndex) => entryIndex !== index); }); }, []); + const [applicationsCategory, setApplicationsCategory] = useState("development"); const [applicationsView, setApplicationsView] = useState<"catalog" | ApplicationId | null>(null); const [cronJobsView, setCronJobsView] = useState(false); @@ -2219,19 +2295,28 @@ export default function App() { null, ); const [libraryRuntimePermissions, setLibraryRuntimePermissions] = useState< - Record + Record< + string, + { + canDelete: boolean; + agentCategory?: "general" | "mpa"; + mpaInstanceId?: string; + } + > >({}); const [hiddenRuntimeIds, setHiddenRuntimeIds] = useState>( () => new Set(), ); const [runtimeUpdateTarget, setRuntimeUpdateTarget] = useState<{ runtimeId: string; + mpaInstanceId?: string; name: string; region: string; appName?: string; currentVersion?: number | null; etag?: string; editMode?: "source-preserving" | "regenerate"; + mpaProfileOnly?: boolean; configuredMcpEnvKeys?: string[]; configuredRuntimeEnvKeys?: string[]; } | null>(null); @@ -2239,6 +2324,43 @@ export default function App() { defaultCloudRegion(cloudProvider), ); const [focusedDeploymentTaskId, setFocusedDeploymentTaskId] = useState(""); + + useEffect(() => { + if (authStatus !== "authenticated") return; + const controller = new AbortController(); + + const recover = async (initial: MpaAgentOperation) => { + if (recoveredOperationIdsRef.current.has(initial.operationId)) return; + recoveredOperationIdsRef.current.add(initial.operationId); + let operation = initial; + try { + while (!controller.signal.aborted) { + operation = await getMpaAgentOperation( + operation.operationId, + controller.signal, + ); + const task = operationToDeploymentTask(operation); + updateDeploymentTask(task); + if (operation.operationKind === "create") { + setFocusedDeploymentTaskId(task.id); + setFocusedWorkspaceAgentId(""); + setManageAgents(true); + } + if (operation.status !== "active") return; + await new Promise((resolve) => window.setTimeout(resolve, 500)); + } + } finally { + recoveredOperationIdsRef.current.delete(initial.operationId); + } + }; + + void listActiveMpaAgentOperations() + .then((operations) => Promise.all(operations.map(recover))) + .catch(() => { + // Recovery is best-effort; normal Runtime discovery remains available. + }); + return () => controller.abort(); + }, [authStatus, operationToDeploymentTask, updateDeploymentTask]); const [focusedWorkspaceAgentId, setFocusedWorkspaceAgentId] = useState(""); const [agentDetailTarget, setAgentDetailTarget] = useState(null); @@ -2465,7 +2587,13 @@ export default function App() { for (const agent of targets) { try { if (!agent.region) throw new Error(appText("errors.runtimeRegionMissingForDelete")); - await deleteRuntime(agent.runtimeId, agent.region); + await deleteRuntime(agent.runtimeId, agent.region, { + agentCategory: agent.agentCategory, + mpaInstanceId: + agent.agentCategory === "mpa" + ? agent.mpaInstanceId ?? agent.runtimeId + : undefined, + }); removeRuntimeConnection(agent.runtimeId); deletedRuntimeIds.add(agent.runtimeId); deletedAgentIds.add(agent.id); @@ -2581,7 +2709,11 @@ export default function App() { Object.fromEntries( runtimes.map((runtime) => [ runtime.runtimeId, - { canDelete: runtime.canDelete }, + { + canDelete: runtime.canDelete, + agentCategory: runtime.agentCategory, + mpaInstanceId: runtime.mpaInstanceId, + }, ]), ), ); @@ -2665,7 +2797,12 @@ export default function App() { result.runtimeName, result.region ?? fallbackRegion, result.version, - { waitForReady: true, agentName: result.agentName }, + { + waitForReady: true, + agentName: result.agentName, + agentCategory: result.mpaOperation ? "mpa" : "general", + mpaInstanceId: result.mpaInstanceId, + }, ); setConnections(loadConnections()); setAgentInfoRefreshKey((key) => key + 1); @@ -2693,6 +2830,11 @@ export default function App() { const responseAnnotationContextsRef = useRef< Map >(new Map()); + const transcriptTurns = useMemo(() => groupMpaTranscriptTurns( + turns, + isMpaA2aRuntimeApp(appName), + activeConversationBusy || presentingStream, + ), [turns, appName, connections, activeConversationBusy, presentingStream]); const responseAnnotationRuntimeAvailable = connections.some( (connection) => Boolean(connection.runtimeId && connection.region) && @@ -2700,7 +2842,7 @@ export default function App() { ); useLayoutEffect(() => { const contexts = new Map(); - turns.forEach((turn, index) => { + transcriptTurns.forEach((turn, index) => { const feedbackEventId = turn.meta?.eventId ?? ""; const canRate = Boolean( responseAnnotationRuntimeAvailable && feedbackEventId && turnText(turn), @@ -2708,7 +2850,7 @@ export default function App() { const turnIsStreaming = assistantTurnIsStreaming( turn, index, - turns.length, + transcriptTurns.length, activeConversationBusy, presentingStream, ); @@ -2720,7 +2862,7 @@ export default function App() { !turnAwaitingAuth(turn) ), turn, - input: canRate ? previousUserTurnText(turns, index) : "", + input: canRate ? previousUserTurnText(transcriptTurns, index) : "", }); }); responseAnnotationContextsRef.current = contexts; @@ -2729,7 +2871,7 @@ export default function App() { cloudProvider, presentingStream, responseAnnotationRuntimeAvailable, - turns, + transcriptTurns, ]); const openResponseAnnotation = useCallback(() => { const selection = window.getSelection(); @@ -2875,7 +3017,10 @@ export default function App() { setUserInfo(id.info); setLocalMode(!!id.local); setAuthStatus(id.status); - if (id.status === "authenticated") { + if ( + id.status === "authenticated" && + !localStorage.getItem(LS.session) + ) { restoredRef.current = true; agentSelectionClearedRef.current = true; localStorage.removeItem(LS.app); @@ -3426,29 +3571,36 @@ export default function App() { preparedSelection?.agentId === appName && preparedSelection.userId === userId ) { + agentInfoSelectionRef.current = appName; setCapabilitiesLoading(false); return; } let cancelled = false; - setAgentInfo(null); + const controller = new AbortController(); + if (agentInfoSelectionRef.current !== appName) setAgentInfo(null); + agentInfoSelectionRef.current = appName; setInvocation(emptyInvocation()); if (authStatus !== "authenticated" || myAgents || agentDetailTarget || !appName) { setCapabilitiesLoading(false); return; } setCapabilitiesLoading(true); - getAgentInfo(appName) + getAgentInfo(appName, controller.signal) .then((info) => { if (!cancelled) setAgentInfo(info); }) .catch(() => { - if (!cancelled) setAgentInfo(null); + if (!cancelled) setAgentInfo(current => current?.agentCategory === "mpa" ? { + ...current, + mpa: {agentsMd: null, agentsMdStatus: "error", skillSpaces: [], skillSpacesStatus: "error"}, + } : null); }) .finally(() => { if (!cancelled) setCapabilitiesLoading(false); }); return () => { cancelled = true; + controller.abort(); }; }, [agentDetailTarget, appName, agentInfoRefreshKey, authStatus, myAgents]); useEffect(() => { @@ -3688,6 +3840,33 @@ export default function App() { } } + async function refreshSessionTranscript( + app: string, + sid: string, + expectedEventId: string, + ): Promise { + try { + const session = await getSession(app, userId, sid); + const nextTurns = eventsToTurns(session.events ?? [], session.state, { + mpaA2a: isMpaA2aRuntimeApp(app), + }); + setTurnsBySession((current) => { + const existing = current[sid] ?? []; + const reconciled = reconcilePersistedTranscript( + existing, + nextTurns, + expectedEventId, + ); + return reconciled === existing + ? current + : { ...current, [sid]: reconciled }; + }); + } catch { + // Live SSE remains the primary display path. This is only a best-effort + // reconciliation against the Runtime's persisted transcript after done. + } + } + function openSandboxLaunch( kind: "codex" | SandboxAgentKind = "codex", fromAgents = false, @@ -4697,7 +4876,6 @@ export default function App() { setInitializingSession(false); setPendingTurns([]); setInvocation(emptyInvocation()); - setDraftStudioToolIds([]); discardDraftAttachments(attachments); setAttachments([]); if (abandonedSession) void abandonDraftSession(abandonedSession); @@ -4860,7 +5038,7 @@ export default function App() { async function pickSession(id: string) { if (sandboxSession) exitSandboxSession(); - if (id === sessionId) return; + if (id === sessionId && turnsBySession[id] !== undefined) return; viewSidRef.current = id; setError(""); setInitializingSession(false); @@ -4876,7 +5054,9 @@ export default function App() { setLoadingSession(true); try { const s = await getSession(appName, userId, id); - setTurnsFor(id, eventsToTurns(s.events ?? [], s.state)); + setTurnsFor(id, eventsToTurns(s.events ?? [], s.state, { + mpaA2a: isMpaA2aRuntimeApp(appName), + })); setTokenUsageBySession((current) => ({ ...current, [sessionUsageKey(appName, id)]: aggregateTokenUsage(s.events ?? []), @@ -5065,24 +5245,201 @@ export default function App() { ); } + const activeTurnControl = sessionId ? turnControlBySession[sessionId] ?? null : null; + const activeTurnIsControllable = Boolean( + activeTurnControl?.allowedActions.length + || ["running", "pausing", "paused", "resuming", "interrupting", "cancelling"] + .includes(activeTurnControl?.state ?? ""), + ); + + function commitFinishedAssistantTurns( + sid: string, + eventProjector: ReturnType, + ): { hasVisibleContent: boolean; eventId: string } { + let hasVisibleContent = false; + let eventId = ""; + for (const unfinished of eventProjector.finish()) { + if (turnHasVisibleContent(unfinished)) { + hasVisibleContent = true; + eventId = unfinished.meta?.eventId ?? eventId; + } + setTurnsFor(sid, (turns) => + upsertProjectedAssistantTurn(turns, unfinished), + ); + } + return { hasVisibleContent, eventId }; + } + + async function streamTurnContinuation(sid: string, control: TurnControlState) { + const ctrl = new AbortController(); + streamAbortsRef.current.set(sid, ctrl); + setStreaming(sid, true); + setTurnControlBySession((current) => { + if (!current[sid]) return current; + const next = { ...current }; + delete next[sid]; + return next; + }); + const nextTurnControls = { ...turnControlBySessionRef.current }; + delete nextTurnControls[sid]; + turnControlBySessionRef.current = nextTurnControls; + startStreamPresentation(sid); + viewSidRef.current = sid; + const eventProjector = createAssistantEventProjector( + `${sid}-continue-${crypto.randomUUID()}`, + undefined, + { mpaA2a: isMpaA2aRuntimeApp(appName) }, + ); + let streamFailed = false; + try { + let finalEventId = ""; + let hasCompletedReply = false; + for await (const event of continueTurnSSE({ + appName, + sessionId: sid, + taskId: control.taskId, + expectedGeneration: control.generation, + idempotencyKey: `mpa-continue:${control.taskId}:${control.generation}`, + lastEventId: lastCompletedEventId(turnsBySession[sid]), + signal: ctrl.signal, + onRuntimeContext: (context) => { + setRuntimeLogTargetsBySession((current) => ({ + ...current, + [`${appName}\n${sid}`]: context, + })); + }, + })) { + if (ctrl.signal.aborted) break; + const errMsg = event.error ?? event.errorMessage ?? event.error_message; + if (typeof errMsg === "string" && errMsg) { + streamFailed = true; + if (viewSidRef.current === sid) setError(errMsg); + break; + } + addTokenUsageFor(appName, sid, event); + const projection = eventProjector.project(event); + if (projection.ignored) continue; + if ( + projection.completed && + turnHasVisibleContent(projection.turn) + ) { + hasCompletedReply = true; + finalEventId = projection.turn.meta?.eventId ?? finalEventId; + } + setTurnsFor(sid, (turns) => + upsertProjectedAssistantTurn(turns, projection.turn), + ); + } + const finalized = commitFinishedAssistantTurns(sid, eventProjector); + hasCompletedReply = hasCompletedReply || finalized.hasVisibleContent; + finalEventId = finalized.eventId || finalEventId; + if (!ctrl.signal.aborted && !streamFailed && !hasCompletedReply) { + streamFailed = true; + if (viewSidRef.current === sid) { + setError(runSseIncompleteResponseError()); + } + } + void refreshSessions(appName); + if (!ctrl.signal.aborted && !streamFailed && finalEventId) { + void refreshSessionTranscript(appName, sid, finalEventId); + } + if (!ctrl.signal.aborted && !streamFailed && finalEventId) { + automaticEvaluationStatusRefreshRef.current(); + } + } catch (e) { + streamFailed = true; + if ( + (e as Error)?.name !== "AbortError" && + !ctrl.signal.aborted && + viewSidRef.current === sid + ) { + setError(e instanceof Error ? e.message : String(e)); + } + } finally { + commitFinishedAssistantTurns(sid, eventProjector); + if (streamAbortsRef.current.get(sid) === ctrl) { + streamAbortsRef.current.delete(sid); + setStreaming(sid, false); + finishStreamPresentation(sid); + } + } + } + + async function applyTurnControl(action: "pause" | "resume") { + if (!sessionId || !appName || !activeTurnControl) return; + setTurnControlBusy(true); + try { + const next = await controlTurn( + appName, sessionId, action, activeTurnControl.generation, + ); + if (action === "resume" && next.resumeDisposition === "new_turn_required") { + streamAbortsRef.current.get(sessionId)?.abort(); + await streamTurnContinuation(sessionId, next); + return; + } + setTurnControlBySession((current) => ({ ...current, [sessionId]: next })); + turnControlBySessionRef.current = { + ...turnControlBySessionRef.current, + [sessionId]: next, + }; + } catch (cause) { + if (cause instanceof TurnControlConflictError) { + const next = cause.authoritativeState; + setTurnControlBySession((current) => ({ ...current, [sessionId]: next })); + turnControlBySessionRef.current = { + ...turnControlBySessionRef.current, + [sessionId]: next, + }; + return; + } + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setTurnControlBusy(false); + } + } + async function send( text: string, atts: Attachment[] = [], selectedInvocation: FrontendInvocation = emptyInvocation(), messageSource: AgentMessageSource = "composer", selectedPlatformTools?: readonly string[], + allowWhileBusy = false, ) { // `busy` here = the CURRENT session is already streaming (can't double-send // to it). Other sessions can stream concurrently. if ( (!text.trim() && atts.length === 0) || - conversationBusy || + (!allowWhileBusy && conversationBusy) || !appName || !userId ) return; + const selectableModels = agentInfo?.selectableModels ?? []; + const modelSelectionKey = sessionId || `new:${appName}`; + const selectedModel = selectedModelBySession[modelSelectionKey] || ""; + const requestedModel = selectableModels.length > 1 + ? selectableModels.includes(selectedModel) + ? selectedModel + : agentInfo?.model || selectableModels[0] || "" + : ""; + const mountedSkillInvocation = { + ...selectedInvocation, + skills: [ + ...selectedInvocation.skills, + ...(agentInfo?.agentCategory === "mpa" ? [] : selectedSessionSkills).map((skill) => ({ + name: skill.name, + description: skill.description ?? "", + skillSpaceId: skill.skillSpaceId, + skillId: skill.skillId, + version: skill.version, + })), + ].filter((skill, index, values) => ( + values.findIndex((candidate) => candidate.name === skill.name) === index + )), + }; setError(""); const createsSession = !sessionId; - let platformTools = [...(selectedPlatformTools ?? selectedStudioToolIds)]; + let platformTools = [...(selectedPlatformTools ?? environmentStudioToolIds)]; const environmentMounts = createsSession ? [] : environmentMountsBySession[ @@ -5104,8 +5461,8 @@ export default function App() { : null; const userBlocks: Turn["blocks"] = []; - if (selectedInvocation.skills.length > 0 || selectedInvocation.targetAgent) { - userBlocks.push({ kind: "invocation", value: selectedInvocation }); + if (mountedSkillInvocation.skills.length > 0 || mountedSkillInvocation.targetAgent) { + userBlocks.push({ kind: "invocation", value: mountedSkillInvocation }); } if (atts.length) userBlocks.push({ @@ -5161,6 +5518,9 @@ export default function App() { if (selectedTask) { const requiredTools = NEW_CHAT_TASK_TOOLS[selectedTask]; const agentTools = new Set(agentInfo?.tools ?? []); + const availableStudioToolIds = new Set( + studioToolCapabilities?.tools.map((tool) => tool.id) ?? [], + ); const availableTools = new Set([ ...agentTools, ...(studioToolRuntime ? availableStudioToolIds : []), @@ -5201,15 +5561,15 @@ export default function App() { createsSession ? optimisticTurns : [...current, ...optimisticTurns], ); if (createsSession) { - if (studioToolRuntime) { - const key = studioToolSelectionKey(appName, userId, sid); - setStudioToolIdsBySession((current) => ({ + viewSidRef.current = sid; + setSessionId(sid); + if (requestedModel && createsSession) { + setSelectedModelBySession((current) => ({ ...current, - [key]: [...platformTools], + [sid]: requestedModel, + [modelSelectionKey]: "", })); } - viewSidRef.current = sid; - setSessionId(sid); setPendingTurns([]); setInitializingSession(false); } @@ -5218,20 +5578,29 @@ export default function App() { const ctrl = new AbortController(); streamAbortsRef.current.set(sid, ctrl); setStreaming(sid, true); + if (agentInfo?.turnLifecycleControl && currentRuntime) { + setTurnControlBySession((current) => { + if (!current[sid]) return current; + const next = { ...current }; + delete next[sid]; + return next; + }); + const nextTurnControls = { ...turnControlBySessionRef.current }; + delete nextTurnControls[sid]; + turnControlBySessionRef.current = nextTurnControls; + } startStreamPresentation(sid); viewSidRef.current = sid; - setActiveAgentBySession((m) => ({ ...m, [sid]: "" })); - setSeenAgentsBySession((m) => ({ ...m, [sid]: new Set() })); - setExecPathBySession((m) => ({ ...m, [sid]: [] })); - const eventProjector = createAssistantEventProjector( `${sid}-${crypto.randomUUID()}`, optimisticAssistantTurn, + { mpaA2a: isMpaA2aRuntimeApp(appName) }, ); let streamFailed = false; let streamError: unknown = null; try { + const mpaRunConfig = await resolveMpaRunConfig(sid, ctrl.signal); let finalEventId = ""; let hasCompletedReply = false; for await (const event of runSSE({ @@ -5239,8 +5608,12 @@ export default function App() { userId, sessionId: sid, text, + modelId: requestedModel, + idempotencyKey: mpaRunConfig?.idempotencyKey, + executionConfigVersion: mpaRunConfig?.executionConfigVersion, + lastEventId: lastCompletedEventId(turnsBySession[sid]), attachments: atts, - invocation: selectedInvocation, + invocation: mountedSkillInvocation, platformTools: studioToolRuntime ? platformTools : undefined, environmentMounts: studioToolRuntime && environmentMounts.length > 0 ? environmentMounts @@ -5261,8 +5634,6 @@ export default function App() { if (viewSidRef.current === sid) setError(errMsg); break; } - // Live topology: author + transfer/end signals, keyed by session. - applyStreamSignals(sid, event); addTokenUsageFor(appName, sid, event); const projection = eventProjector.project(event); if (projection.ignored) continue; @@ -5277,6 +5648,9 @@ export default function App() { upsertProjectedAssistantTurn(turns, projection.turn), ); } + const finalized = commitFinishedAssistantTurns(sid, eventProjector); + hasCompletedReply = hasCompletedReply || finalized.hasVisibleContent; + finalEventId = finalized.eventId || finalEventId; if (!ctrl.signal.aborted && !streamFailed && !hasCompletedReply) { streamFailed = true; streamError = runSseIncompleteResponseError(); @@ -5285,6 +5659,9 @@ export default function App() { } } void refreshSessions(appName); + if (!ctrl.signal.aborted && !streamFailed && finalEventId) { + void refreshSessionTranscript(appName, sid, finalEventId); + } if (trackRuntimeMessage && ctrl.signal.aborted) { messageOperation?.fail({ sessionId: String(sid), @@ -5325,11 +5702,7 @@ export default function App() { setError(e instanceof Error ? e.message : String(e)); } } finally { - for (const unfinished of eventProjector.finish()) { - setTurnsFor(sid, (turns) => - upsertProjectedAssistantTurn(turns, unfinished), - ); - } + commitFinishedAssistantTurns(sid, eventProjector); if ( !ctrl.signal.aborted && streamFailed && @@ -5338,11 +5711,11 @@ export default function App() { ) { setInput((current) => current.trim() ? current : text); } - if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid); - setStreaming(sid, false); - finishStreamPresentation(sid); - setActiveAgentBySession((m) => ({ ...m, [sid]: "" })); - setExecPathBySession((m) => ({ ...m, [sid]: [] })); + if (streamAbortsRef.current.get(sid) === ctrl) { + streamAbortsRef.current.delete(sid); + setStreaming(sid, false); + finishStreamPresentation(sid); + } } } @@ -5403,13 +5776,14 @@ export default function App() { studioToolSelectionKey(appName, userId, sid) ] ?? []; const resumedPlatformTools = environmentMounts.length > 0 - ? [...new Set([...selectedStudioToolIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])] - : selectedStudioToolIds; + ? [...ENVIRONMENT_STUDIO_TOOL_IDS] + : []; const eventProjector = createAssistantEventProjector( `${sid}-${crypto.randomUUID()}`, lastTurn?.role === "assistant" ? { ...lastTurn, blocks: base } : undefined, + { mpaA2a: isMpaA2aRuntimeApp(appName) }, ); try { let finalEventId = ""; @@ -5422,6 +5796,7 @@ export default function App() { functionResponses: [ { id: block.callId, name: "adk_request_credential", response }, ], + lastEventId: lastCompletedEventId(turnsBySession[sid]), platformTools: studioToolRuntime ? resumedPlatformTools : undefined, environmentMounts: studioToolRuntime && environmentMounts.length > 0 ? environmentMounts @@ -5441,7 +5816,6 @@ export default function App() { if (viewSidRef.current === sid) setError(errMsg); break; } - applyStreamSignals(sid, event); addTokenUsageFor(appName, sid, event); const projection = eventProjector.project(event); if (projection.ignored) continue; @@ -5456,6 +5830,9 @@ export default function App() { upsertProjectedAssistantTurn(turns, projection.turn), ); } + const finalized = commitFinishedAssistantTurns(sid, eventProjector); + hasCompletedReply = hasCompletedReply || finalized.hasVisibleContent; + finalEventId = finalized.eventId || finalEventId; if (!ctrl.signal.aborted && !streamFailed && !hasCompletedReply) { streamFailed = true; if (viewSidRef.current === sid) { @@ -5463,6 +5840,9 @@ export default function App() { } } void refreshSessions(appName); + if (!ctrl.signal.aborted && !streamFailed && finalEventId) { + void refreshSessionTranscript(appName, sid, finalEventId); + } if (!ctrl.signal.aborted && !streamFailed && finalEventId) { automaticEvaluationStatusRefreshRef.current(); } @@ -5476,16 +5856,10 @@ export default function App() { setError(e instanceof Error ? e.message : String(e)); } } finally { - for (const unfinished of eventProjector.finish()) { - setTurnsFor(sid, (turns) => - upsertProjectedAssistantTurn(turns, unfinished), - ); - } + commitFinishedAssistantTurns(sid, eventProjector); if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid); setStreaming(sid, false); finishStreamPresentation(sid); - setActiveAgentBySession((m) => ({ ...m, [sid]: "" })); - setExecPathBySession((m) => ({ ...m, [sid]: [] })); } } @@ -5506,6 +5880,69 @@ export default function App() { region: currentConn.region, } : undefined; + const currentRuntimeId = currentRuntime?.runtimeId ?? ""; + const currentRuntimeRegion = currentRuntime?.region ?? ""; + useEffect(() => { + if ( + !sessionId || + !appName || + !currentRuntimeId || + !agentInfo?.turnLifecycleControl + ) return; + if (!busy && !turnControlBySessionRef.current[sessionId]) return; + let cancelled = false; + let controlUnavailable = false; + const refresh = () => { + if (controlUnavailable) return; + void getTurnControl(appName, sessionId) + .then((state) => { + if (!cancelled) { + controlUnavailable = false; + setTurnControlBySession((current) => ( + current[sessionId]?.taskId === state.taskId + && current[sessionId]?.state === state.state + && current[sessionId]?.generation === state.generation + && current[sessionId]?.allowedActions.join("\0") + === state.allowedActions.join("\0") + ? current + : { ...current, [sessionId]: state } + )); + turnControlBySessionRef.current = { + ...turnControlBySessionRef.current, + [sessionId]: state, + }; + } + }) + .catch(() => { + if (!cancelled) { + const hadKnownControl = Boolean(turnControlBySessionRef.current[sessionId]); + setTurnControlBySession((current) => { + const next = { ...current }; + delete next[sessionId]; + return next; + }); + const next = { ...turnControlBySessionRef.current }; + delete next[sessionId]; + turnControlBySessionRef.current = next; + if (!hadKnownControl) controlUnavailable = true; + } + }); + }; + refresh(); + const timer = window.setInterval(() => { + const current = turnControlBySessionRef.current[sessionId]; + if (current && ["completed", "failed", "rejected", "cancelled", "canceled", "interrupted", "orphaned"].includes(current.state)) return; + refresh(); + }, 1000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [ + agentInfo?.turnLifecycleControl, + appName, + busy, + currentRuntimeId, + currentRuntimeRegion, + sessionId, + ]); const selectedDraftStudioRuntime = draftStudioRuntime?.appName === appName ? draftStudioRuntime : undefined; // Local Agents execute through this Studio process, so use the synthetic @@ -5524,7 +5961,7 @@ export default function App() { useEffect(() => { let cancelled = false; setStudioToolCapabilities(null); - setStudioToolsError(""); + setSessionEnvironmentsError(""); if ( authStatus !== "authenticated" || !access || @@ -5545,7 +5982,7 @@ export default function App() { }) .catch((cause) => { if (cancelled) return; - setStudioToolsError( + setSessionEnvironmentsError( cause instanceof Error ? cause.message : appText("errors.localToolsLoadFailed"), ); }) @@ -5571,11 +6008,23 @@ export default function App() { setSessionEnvironmentsLoading(true); setSessionEnvironmentsError(""); try { - const [items, workspaces] = await Promise.all([ + const environmentResults = await Promise.allSettled([ listEnvironments(controller.signal), listWorkspaces(controller.signal), ]); if (controller.signal.aborted) return; + const items = environmentResults[0].status === "fulfilled" + ? environmentResults[0].value + : []; + const workspaces = environmentResults[1].status === "fulfilled" + ? environmentResults[1].value + : []; + const environmentErrors = environmentResults.flatMap((result) => + result.status === "rejected" && + !isOptionalStudioStorageUnavailable(result.reason) + ? [result.reason instanceof Error ? result.reason.message : String(result.reason)] + : [] + ); const availableEnvironments = items.filter((environment) => ["aio-sandbox", "codex-sandbox"].includes(environment.baseEnvironment) && environment.latestVersion?.status === "available" && @@ -5593,6 +6042,7 @@ export default function App() { // workspaces disappear from both the picker and existing Session mounts. setSessionEnvironments(availableEnvironments); setSessionWorkspaces(workspaces); + setSessionEnvironmentsError(environmentErrors.join("\n")); setEnvironmentMountsBySession((current) => Object.fromEntries( Object.entries(current).map(([key, selections]) => [ key, @@ -5699,6 +6149,14 @@ export default function App() { canDelete: entry.runtimeId ? libraryRuntimePermissions[entry.runtimeId]?.canDelete === true : false, + agentCategory: entry.runtimeId + ? entry.agentCategory ?? + libraryRuntimePermissions[entry.runtimeId]?.agentCategory + : entry.agentCategory, + mpaInstanceId: entry.runtimeId + ? entry.mpaInstanceId ?? + libraryRuntimePermissions[entry.runtimeId]?.mpaInstanceId + : entry.mpaInstanceId, })); const orderedWorkspaceAgentEntries: AgentEntry[] = (() => { if (workspaceAgentEntries.length === 0) return workspaceAgentEntries; @@ -5718,51 +6176,24 @@ export default function App() { const activeStudioToolSelectionKey = sessionId ? studioToolSelectionKey(appName, userId, sessionId) : ""; - const storedStudioToolIds = sessionId - ? (studioToolIdsBySession[activeStudioToolSelectionKey] ?? []) - : draftStudioToolIds; const allStudioToolIds = new Set( studioToolCapabilities?.tools.map((tool) => tool.id) ?? [], ); - const availableStudioToolIds = new Set( - [...allStudioToolIds].filter((toolId) => !agentInfo?.tools.includes(toolId)), - ); const selectedEnvironmentMounts = sessionId ? environmentMountsBySession[activeStudioToolSelectionKey] ?? [] : []; - const selectedEnvironmentWorkspaceIds = sessionId - ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? [] - : []; const canMountSessionEnvironment = ENVIRONMENT_STUDIO_TOOL_IDS.every((toolId) => allStudioToolIds.has(toolId) ); - const selectedStudioToolIds = [...new Set([ - ...storedStudioToolIds.filter((toolId) => availableStudioToolIds.has(toolId)), - ...(selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment - ? [...ENVIRONMENT_STUDIO_TOOL_IDS] - : []), - ])]; - const visibleStudioTools = studioToolCapabilities?.tools.filter((tool) => - !ENVIRONMENT_STUDIO_TOOL_IDS.includes( - tool.id as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number], - ) || selectedEnvironmentMounts.length > 0 - ) ?? []; - const updateSelectedStudioToolIds = (selectedIds: string[]) => { - const next = [...new Set([ - ...selectedIds, - ...(selectedEnvironmentMounts.length > 0 ? [...ENVIRONMENT_STUDIO_TOOL_IDS] : []), - ])].filter((toolId) => - availableStudioToolIds.has(toolId), - ); - if (!sessionId) { - setDraftStudioToolIds(next); - return; - } - setStudioToolIdsBySession((current) => ({ - ...current, - [activeStudioToolSelectionKey]: next, - })); - }; + const environmentStudioToolIds = selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment + ? [...ENVIRONMENT_STUDIO_TOOL_IDS] + : []; + const selectedSessionSkills = sessionId + ? sessionSkillsBySession[activeStudioToolSelectionKey] ?? [] + : []; + const selectedEnvironmentWorkspaceIds = sessionId + ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? [] + : []; const updateSelectedEnvironments = async ( selections: SessionEnvironmentMountSelection[], workspaceIds: string[] = [], @@ -5799,26 +6230,8 @@ export default function App() { ...current, [activeStudioToolSelectionKey]: workspaceIds, })); - setStudioToolIdsBySession((current) => { - const selectedIds = current[activeStudioToolSelectionKey] ?? []; - return { - ...current, - [activeStudioToolSelectionKey]: selections.length > 0 - ? [...new Set([...selectedIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])] - : selectedIds.filter((toolId) => !ENVIRONMENT_STUDIO_TOOL_IDS.includes( - toolId as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number], - )), - }; - }); }; - const studioToolsUnavailableReason = studioToolsError - ? studioToolsError - : studioToolCapabilities && !studioToolCapabilities.enabled - ? t("errors.localBffToolsNotConfigured") - : studioToolCapabilities && !studioToolCapabilities.supported - ? t("errors.runtimeBffToolsDisabled") - : ""; const sessionEnvironmentsUnavailableReason = sessionEnvironmentsError || (!studioToolsLoading && studioToolCapabilities && !canMountSessionEnvironment ? t("errors.sandboxToolsUnavailable") @@ -5829,6 +6242,28 @@ export default function App() { remoteAppId(currentConn.id, app) === appName ) ?? agentInfo?.appName ?? currentConn.apps[0] ?? currentConn.name : ""; + async function resolveMpaRunConfig( + sid: string, + signal?: AbortSignal, + ): Promise<{ idempotencyKey: string; executionConfigVersion: number } | null> { + if ( + !isMpaRuntimeApp(appName) || + !currentConn?.runtimeId || + !currentRuntimeAppName + ) { + return null; + } + const config: MpaSessionExecutionConfig = await getMpaSessionExecutionConfig({ + runtimeId: currentConn.runtimeId, + region: currentConn.region ?? defaultCloudRegion(cloudProvider), + sessionId: sid, + signal, + }); + return { + idempotencyKey: `mpa-run:${sid}:${crypto.randomUUID()}`, + executionConfigVersion: config.revision, + }; + } const submitIssueFeedbackForTurn = async (feedback: { issues: IssueFeedbackIssue[]; @@ -6108,6 +6543,7 @@ export default function App() { setConnections(nextConnections); setNewChatCapabilities(capabilities); commitHydratedSessions(id, hydratedSessions); + agentInfoSelectionRef.current = id; setAgentInfo(nextAgentInfo); setCapabilitiesLoading(false); setEvaluatingSids(new Set( @@ -6115,6 +6551,7 @@ export default function App() { .filter((status) => status.state === "running") .map((status) => status.sessionId) ?? [], )); + startNewChat(); setAppName(id); exitAgentDetailContext(); setFocusedDeploymentTaskId(""); @@ -6127,7 +6564,6 @@ export default function App() { setIntelligentDeployment(null); setWorkspaceView(false); setEnvironmentView(false); - startNewChat(); }; const openIntelligentDeploymentChat = async (agentId: string) => { @@ -6138,7 +6574,10 @@ export default function App() { await refreshCurrentAgentAndStartNewChat(id); }; - const openAgentCreateFromMyAgents = (region: string) => { + const openAgentCreateFromMyAgents = ( + region: string, + agentCategory: "general" | "mpa" = "general", + ) => { if (!canCreateRuntimeAgents) { setError(appText("errors.noCreateAgentPermission")); return; @@ -6147,9 +6586,24 @@ export default function App() { setManageAgents(false); setNewRuntimeRegion(region); setImportedDraft(null); - setCreateView(null); - setAddMenuSurface("entry"); - setAddMenu(true); + setRuntimeUpdateTarget(null); + if (agentCategory === "mpa") { + setAddMenu(false); + setAddMenuSurface("entry"); + setCustomCreateMode("custom"); + setCustomCreationSurface("vulcan"); + setEditingDraftId(`draft-${Date.now().toString(36)}`); + editingDraftBaselineRef.current = null; + setCreateView("custom"); + } else { + setEditingDraftId(""); + editingDraftBaselineRef.current = null; + setCreateView(null); + setAddMenuSurface("entry"); + setAddMenu(true); + } + setFocusedDeploymentTaskId(""); + setFocusedWorkspaceAgentId(""); setError(""); }; @@ -6169,6 +6623,10 @@ export default function App() { agent.name, agent.runtime.region, agent.runtime.currentVersion, + { + agentCategory: agent.agentCategory ?? agent.runtime.agentCategory, + mpaInstanceId: agent.runtime.mpaInstanceId, + }, ); operation.succeed({ runtimeRegion: agent.runtime.region, @@ -6190,6 +6648,10 @@ export default function App() { } = {}, ) => { if (!agent.runtime) return; + // Task reads do not depend on the Runtime's chat/session protocol. + setSelectedCronRuntime({ + runtimeId: agent.runtime.runtimeId, name: agent.name, region: agent.runtime.region, + }); try { const agentId = await connectRuntimeForUser( agent, @@ -6215,6 +6677,36 @@ export default function App() { setError(""); }; + const editMpaProfile = (target: MpaProfileEditTarget) => { + if (!canManageAgents && !canCreateRuntimeAgents) { + setError(appText("errors.noManageAgentPermission")); + return; + } + exitAgentDetailContext(); + setImportedDraft(target.draft); + setCustomCreateMode("custom"); + setCustomCreationSurface("vulcan"); + setEditingDraftId(`mpa-profile-${target.runtimeId}`); + editingDraftBaselineRef.current = null; + setFocusedDeploymentTaskId(""); + setFocusedWorkspaceAgentId(""); + setRuntimeUpdateTarget({ + runtimeId: target.runtimeId, + mpaInstanceId: target.mpaInstanceId, + name: target.name, + region: target.region, + appName: target.appName, + currentVersion: target.currentVersion, + etag: target.runtimeRevision, + editMode: "regenerate", + mpaProfileOnly: true, + configuredMcpEnvKeys: configuredMcpEnvKeys(target.draft), + configuredRuntimeEnvKeys: [], + }); + setCreateView("custom"); + setError(""); + }; + const closeAgentDetailPage = () => { exitAgentDetailContext(); setFocusedDeploymentTaskId(""); @@ -6249,8 +6741,6 @@ export default function App() { const openMyAgentsPage = () => { setPlatformFeedbackOrigin(null); if (sandboxSession) exitSandboxSession(); - viewSidRef.current = ""; - setSessionId(""); setCreateView(null); setSkillCenter(false); setAddAgent(false); @@ -6384,6 +6874,10 @@ export default function App() { agent.label, agent.region ?? defaultCloudRegion(cloudProvider), agent.currentVersion, + { + agentCategory: agent.agentCategory, + mpaInstanceId: agent.mpaInstanceId, + }, ); operation.succeed({ runtimeRegion: agent.region, @@ -6412,8 +6906,10 @@ export default function App() { remote: true, runtimeApp: detailConnection?.apps[0], runtimeId: agentDetailTarget.runtime.runtimeId, + mpaInstanceId: agentDetailTarget.runtime.mpaInstanceId, region: agentDetailTarget.runtime.region, currentVersion: agentDetailTarget.runtime.currentVersion, + agentCategory: agentDetailTarget.agentCategory ?? agentDetailTarget.runtime.agentCategory, canDelete: agentDetailTarget.runtime.canDelete, } : null; @@ -6671,7 +7167,7 @@ export default function App() {
- {!sandboxSession && currentRuntime && sessionId && ( + {!sandboxSession && currentRuntime && sessionId && !isMpaRuntimeApp(appName) && !isMpaA2aRuntimeApp(appName) && (
{ + setSelectedModelBySession((current) => ({ + ...current, + [sessionId || `new:${appName}`]: model, + })); + }} + turnControl={activeTurnIsControllable ? activeTurnControl : null} + turnControlBusy={turnControlBusy} + onTurnControl={(action) => void applyTurnControl(action)} tokenUsage={activeTokenUsage} systemTokenEstimate={systemTokenEstimate} allowAttachments={!sandboxSession} @@ -6917,10 +7430,13 @@ export default function App() { cloudProvider, ), isMine: runtime.isMine, + agentCategory: runtime.agentCategory, runtime: { runtimeId: runtime.runtimeId, + mpaInstanceId: runtime.mpaInstanceId, region: runtime.region, currentVersion: runtime.currentVersion, + agentCategory: runtime.agentCategory, canDelete: runtime.canDelete, }, }, @@ -6931,6 +7447,7 @@ export default function App() { setDraftStudioRuntime({ appName: agentId, runtimeId: runtime.runtimeId, + mpaInstanceId: runtime.mpaInstanceId, name: runtime.name, region: runtime.region, }); @@ -7091,7 +7608,7 @@ export default function App() { setCreateView("workspace"); } : undefined} /> ) : cronJobsView ? ( - + ) : applicationsView === "coding-agents" ? ( setApplicationsView("catalog")} @@ -7100,6 +7617,12 @@ export default function App() { setApplicationsView("catalog")} /> + ) : applicationsView === "mpa-channels" ? ( + setApplicationsView("catalog")} + /> ) : applicationsView === "website-integration" ? ( setApplicationsView("catalog")} @@ -7121,7 +7644,7 @@ export default function App() { }} /> ) : applicationsView === "catalog" ? ( - + ) : sandboxAgentWorkspace ? ( void refreshAgentLibrary()} @@ -7368,6 +7893,7 @@ export default function App() { setFocusedWorkspaceAgentId(""); setRuntimeUpdateTarget({ runtimeId: capability.runtime.runtimeId, + mpaInstanceId: capability.runtime.mpaInstanceId, name: capability.runtime.name || runtimeAgent.name || @@ -7380,6 +7906,7 @@ export default function App() { capability.editMode === "source-preserving" ? "source-preserving" : "regenerate", + mpaProfileOnly: false, configuredMcpEnvKeys: configuredMcpEnvKeys(editorDraft), configuredRuntimeEnvKeys: capability.runtime.configuredEnvKeys, @@ -7387,6 +7914,7 @@ export default function App() { setCreateView("custom"); setError(""); }} + onEditMpaProfile={editMpaProfile} onEditDraft={(item) => { exitAgentDetailContext(); setImportedDraft(item.draft); @@ -7784,10 +8312,10 @@ export default function App() { onWheel={onConversationWheel} onTouchMove={onConversationTouchMove} > - {buildTranscriptRows(turns, rootCapabilityNode).map((row) => { + {buildTranscriptRows(transcriptTurns, rootCapabilityNode).map((row) => { const renderTurn = (i: number) => { - const turn = turns[i]; - const isLast = i === turns.length - 1; + const turn = transcriptTurns[i]; + const isLast = i === transcriptTurns.length - 1; if (turn.role === "system") { return turn.activity ? (
- : ) : null + : ) : null ) : ( <> - completeStreamPresentation(sessionId) - : undefined - } - onAction={onAction} - onAuth={onAuth} - onArtifactDownload={(filename, version) => - downloadArtifact(appName, userId, sessionId, filename, version) - } - onArtifactPreview={(filename, version) => - previewArtifact(appName, userId, sessionId, filename, version) - } - onResolveDelivery={resolveIntelligentDelivery} - onResolveDeliveryComparison={resolveIntelligentDeliveryComparison} - onDownloadDelivery={downloadIntelligentDelivery} - onDeployDelivery={setIntelligentDeployment} - onBranchSelect={(branch) => { - setInput(t("conversation.continueBranch", { branch: branch.label })); - }} - /> - {/* Finalized turn that produced no visible answer (e.g. only - thinking + an empty A2UI surface) — show a fallback note. */} - {!turnIsStreaming && !turnHasVisibleContent(turn) && ( + + completeStreamPresentation(sessionId) + : undefined + } + onAction={onAction} + onAuth={onAuth} + onArtifactDownload={(filename, version) => + downloadArtifact(appName, userId, sessionId, filename, version) + } + onArtifactPreview={(filename, version) => + previewArtifact(appName, userId, sessionId, filename, version) + } + onResolveDelivery={resolveIntelligentDelivery} + onResolveDeliveryComparison={resolveIntelligentDeliveryComparison} + onDownloadDelivery={downloadIntelligentDelivery} + onDeployDelivery={setIntelligentDeployment} + onBranchSelect={(branch) => { + setInput(t("conversation.continueBranch", { branch: branch.label })); + }} + /> + + {/* MPA A2A fragments share one request-level empty notice. */} + {shouldShowEmptyAssistantResponse(transcriptTurns, i, turnHasVisibleContent, { + mpaA2a: isMpaA2aRuntimeApp(appName), + turnIsStreaming, + requestIsStreaming: activeConversationBusy || presentingStream, + }) && (
{t("conversation.emptyResponse")}
)} {/* Hide the actions/timestamp row while this turn is still @@ -8027,7 +8560,7 @@ export default function App() { title={t("feedback.reportIssue")} onClick={() => setIssueFeedbackTarget({ turn, - input: previousUserTurnText(turns, i), + input: previousUserTurnText(transcriptTurns, i), })} > @@ -8082,25 +8615,15 @@ export default function App() { return renderTurn(row.turnIndexes[0]); })}
- {!sandboxSession && ( - { + preparedAgentSelectionRef.current = null; + setAgentInfoRefreshKey(key => key + 1); + }} loading={capabilitiesLoading} - activeAgent={activeAgent} - seenAgents={seenAgents} - execPath={execPath} - studioTools={visibleStudioTools} - selectedStudioToolIds={selectedStudioToolIds} - managedStudioToolIds={selectedEnvironmentMounts.length > 0 - ? ENVIRONMENT_STUDIO_TOOL_IDS - : []} - studioToolsLoading={studioToolsLoading} - studioToolsDisabled={conversationBusy} - studioToolsUnavailableReason={studioToolsUnavailableReason} - onStudioToolsChange={ - studioToolRuntime ? updateSelectedStudioToolIds : undefined - } environments={sessionEnvironments} workspaces={sessionWorkspaces} selectedEnvironments={selectedEnvironmentMounts} diff --git a/frontend/src/adk/channels.test.ts b/frontend/src/adk/channels.test.ts new file mode 100644 index 000000000..06fe3a1b0 --- /dev/null +++ b/frontend/src/adk/channels.test.ts @@ -0,0 +1,15 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { channelRequest } from "./client"; + +afterEach(() => vi.unstubAllGlobals()); + +it("routes channel CRUD through the Runtime proxy without browser credentials", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: true }), { status: 200, headers: { "Content-Type": "application/json" } })); + vi.stubGlobal("fetch", fetcher); + await channelRequest({ runtimeId: "runtime-test", region: "cn-beijing" }, "/chat-permissions?channel=feishu&chat_id=group", { method: "DELETE" }); + const [url, init] = fetcher.mock.calls[0]; + expect(String(url)).toContain("/web/runtime-proxy/runtime-test/api/v1/channels/chat-permissions"); + expect(String(url)).toContain("_method=DELETE"); + expect(init.method).toBe("POST"); + expect(new Headers(init.headers).has("X-MPA-Channel-Key")).toBe(false); +}); diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 3841b7ada..fd6e951f7 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -1,3 +1,4 @@ +import { fetchMpaCronTasks, manageMpaTask, fetchMpaRuns, type MpaRuntime } from "./mpaCronTasks"; // Thin client for the Google ADK API server (the same server `veadk frontend` // launches). Uses relative URLs so it works same-origin in production and via // the Vite dev proxy in development. @@ -15,7 +16,7 @@ import { runtimeContextFromResponse, type RuntimeLogTarget, } from "./runtimeLogs"; -import { parseSSE } from "./sse"; +import { parseSSE, SSE_EVENT_NAME } from "./sse"; import { normalizeRuntimeDescription } from "./runtimeDescription"; import { DeploymentStatusUnconfirmedError, @@ -104,6 +105,8 @@ export interface AdkEvent { timestamp?: number; usageMetadata?: AdkUsage; usage_metadata?: AdkUsage; + customMetadata?: Record; + custom_metadata?: Record; // Set when the model/run fails; /run_sse emits it as a `data: {"error": ...}` // frame (also seen as errorMessage / error_message). error?: string; @@ -142,6 +145,8 @@ export interface TraceSpan { export interface AdkSession { id: string; lastUpdateTime?: number; + title?: string; + status?: string; events?: AdkEvent[]; state?: Record; [k: string]: unknown; @@ -377,8 +382,10 @@ export interface AdkEndpoint { base?: string; apiKey?: string; runtimeId?: string; + mpaInstanceId?: string; region?: string; runtimeVersion?: number | null; + agentCategory?: "general" | "mpa"; retryProbe?: boolean; } @@ -397,7 +404,9 @@ interface RemoteApp { base?: string; apiKey?: string; runtimeId?: string; + mpaInstanceId?: string; region?: string; + agentCategory?: "general" | "mpa"; } const remoteApps = new Map(); @@ -414,10 +423,23 @@ function resolve(appName: string): { app: string; ep: AdkEndpoint } { if (!r) return { app: appName, ep: {} }; return { app: r.app, - ep: { base: r.base, apiKey: r.apiKey, runtimeId: r.runtimeId, region: r.region }, + ep: { + base: r.base, + apiKey: r.apiKey, + runtimeId: r.runtimeId, + mpaInstanceId: r.mpaInstanceId, + region: r.region, + agentCategory: r.agentCategory, + }, }; } +function isMpaEndpoint(app: string, ep: AdkEndpoint): boolean { + // Discovery selects the transport; product metadata must not override A2A. + return app !== "a2a-default" && + Boolean(ep.runtimeId && (ep.agentCategory === "mpa" || ep.mpaInstanceId)); +} + /** fetch wrapper. Routing, in priority order: * 1. `runtimeId` → same-origin `/web/runtime-proxy/{id}{path}` (server injects * the apikey; apikey never reaches the browser). @@ -519,6 +541,22 @@ function formatErrorDetail(detail: unknown): string { return ""; } +async function httpErrorCode(res: Response): Promise { + const text = await res.clone().text().catch(() => ""); + if (!text) return ""; + try { + const data = JSON.parse(text) as { detail?: unknown; error?: unknown }; + const detail = data.detail ?? data.error; + if (typeof detail === "string") return detail; + if (detail && typeof detail === "object" && "code" in detail) { + return String((detail as { code?: unknown }).code ?? ""); + } + } catch { + return ""; + } + return ""; +} + export async function httpErrorMessage( res: Response, fallback: string, @@ -551,6 +589,17 @@ export async function httpErrorMessage( } } +async function mpaRuntimeSessionListError(res: Response): Promise { + const detail = await httpErrorMessage( + res, + adkT("client.mpaRuntimeSessionsLoadFailed"), + ); + if (res.status === 401 && /X-Jwt-Token/i.test(detail)) { + return adkT("client.mpaRuntimeLegacyAuthUnsupported", { detail }); + } + return detail; +} + export async function listModelApiKeys( signal?: AbortSignal, refresh = false, @@ -843,6 +892,59 @@ export async function createSession( userId: string, ): Promise { const { app, ep } = resolve(appName); + if (isMpaEndpoint(app, ep)) { + const mpaInstanceId = await resolveMpaRuntimeInstanceId(ep); + const statusRes = await apiFetch( + `/api/v1/agents/${encodeURIComponent(mpaInstanceId)}/profile-status`, + { cache: "no-store" }, + ep, + ); + const profileNotFound = + !statusRes.ok && (await httpErrorCode(statusRes)) === "profile_not_found"; + if (!statusRes.ok && !profileNotFound) { + const fallback = adkT("client.createSessionFailedWithStatus", { + status: statusRes.status, + }); + const detail = await httpErrorMessage( + statusRes, + adkT("client.createSessionFailed"), + ); + throw new Error( + detail === fallback + ? fallback + : adkT("common.fallbackWithDetail", { fallback, detail }), + ); + } + const profile = profileNotFound + ? {} + : ((await statusRes.json()) as Partial); + const profileRevision = Number(profile.profileRevision ?? 0); + const res = await apiFetch( + "/api/v1/sessions", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mpaInstanceId, + profileRevision, + }), + }, + ep, + ); + if (!res.ok) { + const fallback = adkT("client.createSessionFailedWithStatus", { + status: res.status, + }); + const detail = await httpErrorMessage(res, adkT("client.createSessionFailed")); + throw new Error( + detail === fallback + ? fallback + : adkT("common.fallbackWithDetail", { fallback, detail }), + ); + } + const session = await res.json(); + return String(session.sessionId ?? session.id ?? ""); + } const res = await apiFetch( `/apps/${app}/users/${encodeURIComponent(userId)}/sessions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, @@ -857,13 +959,39 @@ export async function createSession( return session.id; } +async function resolveMpaRuntimeInstanceId(ep: AdkEndpoint): Promise { + const existing = ep.mpaInstanceId?.trim(); + if (existing) return existing; + const runtimeId = ep.runtimeId?.trim() ?? ""; + if (!runtimeId) return ""; + try { + const detail = await getRuntimeDetail(runtimeId, ep.region ?? "cn-beijing"); + const fromDetail = detail.mpaInstanceId?.trim(); + if (fromDetail) return fromDetail; + const fromEnv = detail.envs.find((item) => item.key === "MPA_AGENT_ID") + ?.value.trim(); + if (fromEnv) return fromEnv; + } catch { + /* Preserve the legacy fallback for runtimes without visible metadata. */ + } + return runtimeId; +} + export async function listSessions( appName: string, userId: string, ): Promise { const { app, ep } = resolve(appName); + if (isMpaEndpoint(app, ep)) { + const res = await apiFetch("/api/v1/sessions", { cache: "no-store" }, ep); + if (!res.ok) throw new Error(await mpaRuntimeSessionListError(res)); + const payload = (await res.json()) as { sessions?: AdkSession[] } | AdkSession[]; + return Array.isArray(payload) ? payload : payload.sessions ?? []; + } const res = await apiFetch(`/apps/${app}/users/${encodeURIComponent(userId)}/sessions`, {}, ep); - if (!res.ok) throw new Error(`list sessions failed: ${res.status}`); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.listSessionsFailed"))); + } return res.json(); } @@ -873,6 +1001,33 @@ export async function getSession( sessionId: string, ): Promise { const { app, ep } = resolve(appName); + if (isMpaEndpoint(app, ep)) { + const res = await apiFetch( + `/api/v1/sessions/${encodeURIComponent(sessionId)}`, + { cache: "no-store" }, + ep, + ); + if (!res.ok) { + const detail = await httpErrorMessage(res, adkT("client.getSessionFailed")); + throw new Error(adkT("client.getSessionFailedWithDetail", { status: res.status, detail })); + } + const session = (await res.json()) as AdkSession; + const eventsRes = await apiFetch( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/events`, + { cache: "no-store" }, + ep, + ); + if (!eventsRes.ok) { + const detail = await httpErrorMessage(eventsRes, adkT("client.getSessionFailed")); + throw new Error(adkT("client.getSessionFailedWithDetail", { + status: eventsRes.status, + detail, + })); + } + const eventsPayload = (await eventsRes.json()) as { events?: AdkEvent[] }; + session.events = Array.isArray(eventsPayload.events) ? eventsPayload.events : []; + return session; + } const res = await apiFetch( `/apps/${app}/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}`, {}, @@ -1214,6 +1369,15 @@ export async function deleteSession( sessionId: string, ): Promise { const { app, ep } = resolve(appName); + if (isMpaEndpoint(app, ep)) { + const res = await apiFetch( + `/api/v1/sessions/${encodeURIComponent(sessionId)}`, + { method: "DELETE" }, + ep, + ); + if (!res.ok && res.status !== 404) throw new Error(`delete session failed: ${res.status}`); + return; + } const res = await apiFetch( `/apps/${app}/users/${encodeURIComponent(userId)}/sessions/${sessionId}`, { method: "DELETE" }, @@ -1233,6 +1397,19 @@ function decodeArtifactData(value: string): Uint8Array { return bytes; } +export async function fetchSessionFile( + appName: string, + sessionId: string, + path: string, + signal: AbortSignal, +): Promise { + const { ep } = resolve(appName); + const url = `/api/v1/sessions/${encodeURIComponent(sessionId)}/files/download?path=${encodeURIComponent(path)}`; + const response = await apiFetch(url, { signal }, ep, TRANSFER_REQUEST_TIMEOUT_MS); + if (!response.ok) throw new Error(`SESSION_FILE_HTTP_${response.status}`); + return response.blob(); +} + export async function downloadArtifact( appName: string, userId: string, @@ -1496,18 +1673,37 @@ export interface AgentTarget { } export interface FrontendInvocation { - skills: AgentSkill[]; + skills: SessionSkillSelection[]; targetAgent?: AgentTarget; } +export interface SessionSkillSelection extends AgentSkill { + skillSpaceId?: string; + skillId?: string; + version?: string; +} + +export type MpaReadStatus = "ready" | "unsupported" | "forbidden" | "error"; +export interface MpaAgentMetadata { + agentsMd: string | null; + agentsMdStatus: MpaReadStatus; + skillSpacesStatus: MpaReadStatus; + skillSpaces: Array<{ id: string; region: string }>; +} + /** Introspected metadata for an agent app, served locally or by Agent Server. */ export interface AgentInfo { + agentCategory?: "general" | "mpa"; + mpa?: MpaAgentMetadata; /** Real ADK app id used in runtime proxy paths; display names may differ. */ appName?: string; name: string; description: string; type?: AgentNodeType; model: string; + selectableModels?: string[]; + turnLifecycleControl?: TurnLifecycleCapability; + resourceTopology?: ResourceTopology; tools: string[]; skills: AgentSkill[]; /** False when an older Agent Server omits Skill introspection entirely. */ @@ -1527,8 +1723,9 @@ async function fetchAgentInfo( app: string, ep: AdkEndpoint, loadDraft = true, + signal?: AbortSignal, ): Promise { - const res = await apiFetch(`/web/agent-info/${app}`, {}, ep); + const res = await apiFetch(`/web/agent-info/${app}`, { signal }, ep); if (!res.ok) throw new Error(`agent-info failed: ${res.status}`); const info = (await res.json()) as Partial; if (loadDraft && !info.draft) { @@ -1544,10 +1741,15 @@ async function fetchAgentInfo( } return { appName: app, + agentCategory: info.agentCategory, + mpa: info.mpa, name: info.name ?? app, description: info.description ?? "", type: info.type, model: info.model ?? "", + selectableModels: info.selectableModels ?? [], + turnLifecycleControl: info.turnLifecycleControl, + resourceTopology: info.resourceTopology, tools: info.tools ?? [], skillsPreviewSupported: Array.isArray(info.skills), skills: info.skills ?? [], @@ -1559,9 +1761,277 @@ async function fetchAgentInfo( }; } -export async function getAgentInfo(appName: string): Promise { +function isAgentInfoNotFound(error: unknown): boolean { + return /agent-info failed:\s*404\b/i.test(String(error)); +} + +async function fetchAgentInfoWithA2aFallback( + app: string, + ep: AdkEndpoint, + loadDraft = true, + signal?: AbortSignal, +): Promise { + try { + return await fetchAgentInfo(app, ep, loadDraft, signal); + } catch (error) { + if (app === "a2a-default" || !isAgentInfoNotFound(error)) throw error; + return fetchAgentInfo("a2a-default", ep, loadDraft, signal); + } +} + +export type TurnControlAction = "pause" | "resume"; + +export interface TurnLifecycleCapability { + actions: TurnControlAction[]; + pauseMode: "cooperative-safe-point" | string; + processReplacementResume: boolean; +} + +export interface ResourceTopology { + nodes: Array<{ id: string; kind: string; name: string; status: string; resourceId?: string }>; + edges: Array<{ source: string; target: string; relation: string }>; +} + +export interface TurnControlState { + taskId: string; + state: string; + generation: number; + desiredState?: string | null; + safePoint?: string | null; + checkpoint?: Record | null; + allowedActions: TurnControlAction[]; + idempotentReplay: boolean; + pausedAt?: string | null; + resumableUntil?: string | null; + resumeDisposition?: "same_turn" | "new_turn_required"; + continuationPrompt?: string | null; +} + +export interface TurnContinueResult { + taskId: string; + sessionId: string; + invocationId: string; + turnId: string; + operationId: string; + executionConfigRevision: number; + continuationOf: string; + idempotentReplay: boolean; +} + +export interface ContinueTurnSseArgs { + appName: string; + sessionId: string; + taskId: string; + expectedGeneration: number; + idempotencyKey: string; + lastEventId?: string; + signal?: AbortSignal; + onRuntimeContext?: (context: RuntimeLogTarget) => void; +} + +export class TurnControlConflictError extends Error { + constructor(readonly authoritativeState: TurnControlState) { + super("Turn state changed before the control request completed"); + this.name = "TurnControlConflictError"; + } +} + +export async function getTurnControl( + appName: string, + sessionId: string, +): Promise { + const { ep } = resolve(appName); + const res = await apiFetch( + `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control`, + { cache: "no-store" }, + ep, + ); + if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed")); + return res.json(); +} + +export async function controlTurn( + appName: string, + sessionId: string, + action: TurnControlAction, + expectedGeneration: number, +): Promise { + const { ep } = resolve(appName); + const res = await apiFetch( + `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control/${action}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID(), + }, + body: JSON.stringify({ expectedGeneration }), + }, + ep, + ); + if (res.status === 409) { + const body = await res.json().catch(() => null) as { detail?: TurnControlState } | null; + if (body?.detail?.taskId) throw new TurnControlConflictError(body.detail); + } + if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed")); + return res.json(); +} + +export async function continueTurn( + appName: string, + sessionId: string, + taskId: string, + expectedGeneration: number, + idempotencyKey: string, +): Promise { + const { ep } = resolve(appName); + const res = await apiFetch( + `/api/v1/a2a/tasks/${encodeURIComponent(taskId)}/continue`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify({ expectedGeneration }), + }, + ep, + ); + if (res.status === 409) { + const body = await res.json().catch(() => null) as { detail?: TurnControlState } | null; + if (body?.detail?.taskId) throw new TurnControlConflictError(body.detail); + } + if (!res.ok) throw new Error(await httpErrorMessage(res, "turn continue failed")); + const result = await res.json() as TurnContinueResult; + if (result.sessionId !== sessionId) { + throw new Error("turn continue returned a different session"); + } + return result; +} + +export async function* continueTurnSSE({ + appName, + sessionId, + taskId, + expectedGeneration, + idempotencyKey, + lastEventId, + signal, + onRuntimeContext, +}: ContinueTurnSseArgs): AsyncGenerator { + const { ep } = resolve(appName); + const normalizedLastEventId = lastEventId?.trim() ?? ""; + const firstEventDeadline = runSseFirstEventDeadline(signal); + let res: Response; + try { + const acceptedRes = await apiFetch( + `/api/v1/a2a/tasks/${encodeURIComponent(taskId)}/continue`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": idempotencyKey, + }, + body: JSON.stringify({ expectedGeneration }), + signal: firstEventDeadline.signal, + }, + ep, + 0, + ); + const runtimeContext = runtimeContextFromResponse( + acceptedRes, + ep.runtimeId ?? "", + ep.region ?? "", + ); + if (runtimeContext) onRuntimeContext?.(runtimeContext); + if (!acceptedRes.ok) { + firstEventDeadline.cleanup(); + const detail = await httpErrorMessage(acceptedRes, "turn continue failed"); + throw new Error( + formatRunSseError(adkT("client.runSseFailedWithDetail", { + status: acceptedRes.status, + detail, + })), + ); + } + const accepted = (await acceptedRes.json()) as TurnContinueResult; + if (accepted.sessionId !== sessionId) { + firstEventDeadline.cleanup(); + throw new Error(formatRunSseError("turn continue returned a different session")); + } + res = await apiFetch( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/sse`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + invocationId: accepted.invocationId, + ...(normalizedLastEventId ? { lastEventId: normalizedLastEventId } : {}), + }), + signal: firstEventDeadline.signal, + }, + ep, + 0, + ); + } catch (error) { + firstEventDeadline.cleanup(); + if (firstEventDeadline.timedOut()) throw new Error(runSseFirstEventTimeoutError()); + if (signal?.aborted || (error as Error)?.name === "AbortError") throw error; + throw new Error(formatRunSseError(error)); + } + const runtimeContext = runtimeContextFromResponse( + res, + ep.runtimeId ?? "", + ep.region ?? "", + ); + if (runtimeContext) onRuntimeContext?.(runtimeContext); + if (!res.ok) { + firstEventDeadline.cleanup(); + const detail = await httpErrorMessage(res, adkT("client.runSessionFailed")); + throw new Error( + formatRunSseError(adkT("client.runSseFailedWithDetail", { + status: res.status, + detail, + })), + ); + } + let receivedEvent = false; + try { + for await (const evt of parseSSE(res)) { + if ( + evt && + typeof evt === "object" && + (evt as Record)[SSE_EVENT_NAME] === "done" + ) { + firstEventDeadline.clearDeadline(); + break; + } + receivedEvent = true; + firstEventDeadline.clearDeadline(); + const event = { ...(evt as AdkEvent) }; + delete (event as Record)[SSE_EVENT_NAME]; + if (typeof event.error === "string") event.error = formatRunSseError(event.error); + if (typeof event.errorMessage === "string") { + event.errorMessage = formatRunSseError(event.errorMessage); + } + if (typeof event.error_message === "string") { + event.error_message = formatRunSseError(event.error_message); + } + yield event; + } + } catch (error) { + if (firstEventDeadline.timedOut()) throw new Error(runSseFirstEventTimeoutError()); + if (signal?.aborted || (error as Error)?.name === "AbortError") throw error; + throw new Error(formatRunSseError(error)); + } finally { + firstEventDeadline.cleanup(); + } + if (!receivedEvent) throw new Error(runSseEmptyResponseError()); +} + +export async function getAgentInfo(appName: string, signal?: AbortSignal): Promise { const { app, ep } = resolve(appName); - return fetchAgentInfo(app, ep, false); + return fetchAgentInfoWithA2aFallback(app, ep, false, signal); } /** Read Agent metadata for a Runtime without connecting or persisting it. */ @@ -1585,7 +2055,7 @@ async function fetchRuntimeAgentInfo( freshCached?.apps[0] || (await fetchRemoteApps("", "", ep))[0]; if (!app) throw new Error(adkT("client.noPreviewableAgent")); - return fetchAgentInfo(app, ep); + return fetchAgentInfoWithA2aFallback(app, ep); } catch (error) { if ( error instanceof RuntimeAccessDeniedError || @@ -1662,6 +2132,18 @@ export function prefetchRuntimeAgentInfo( void getRuntimeAgentInfo(runtimeId, region, knownApp).catch(() => {}); } +export function isMpaRuntimeApp(appName: string): boolean { + const { app, ep } = resolve(appName); + return isMpaEndpoint(app, ep); +} + +/** Product-scoped A2A presentation; general A2A apps must keep their defaults. */ +export function isMpaA2aRuntimeApp(appName: string): boolean { + const { app, ep } = resolve(appName); + return app === "a2a-default" && + Boolean(ep.runtimeId && (ep.agentCategory === "mpa" || ep.mpaInstanceId)); +} + /** One web-search hit (Volcengine WebSearch WebItem, trimmed for the UI). */ export interface WebHit { title: string; @@ -1728,6 +2210,10 @@ export interface RunArgs { userId: string; sessionId: string; text: string; + modelId?: string; + idempotencyKey?: string; + executionConfigVersion?: number; + lastEventId?: string; attachments?: Attachment[]; invocation?: FrontendInvocation; /** Complete set of local BFF tool IDs selected for this run. */ @@ -1814,6 +2300,10 @@ export async function* runSSE({ userId, sessionId, text, + modelId, + idempotencyKey, + executionConfigVersion, + lastEventId, attachments = [], invocation, platformTools, @@ -1866,37 +2356,139 @@ export async function* runSSE({ }, }; } + const normalizedIdempotencyKey = idempotencyKey?.trim() ?? ""; + const normalizedLastEventId = lastEventId?.trim() ?? ""; + const hasExecutionConfigVersion = typeof executionConfigVersion === "number"; + const executionMetadata = hasExecutionConfigVersion || normalizedIdempotencyKey + ? { + veadkExecution: { + ...(hasExecutionConfigVersion ? { executionConfigVersion } : {}), + ...(normalizedIdempotencyKey + ? { idempotencyKey: normalizedIdempotencyKey } + : {}), + }, + } + : undefined; + const customMetadata = + invocationMetadata || executionMetadata + ? { + ...(invocationMetadata ? { veadkInvocation: invocationMetadata } : {}), + ...(executionMetadata ?? {}), + } + : undefined; + const isMpa = isMpaEndpoint(app, ep); + if (isMpa && ep.runtimeId) { + const region = ep.region ? `?region=${encodeURIComponent(ep.region)}` : ""; + try { + const prewarm = await studioFetch( + `/web/mpa/identity-prewarm/${encodeURIComponent(ep.runtimeId)}${region}`, + { + method: "POST", + headers: { "X-Requested-With": "XMLHttpRequest" }, + signal, + }, + 10_000, + ); + if (!prewarm.ok) { + console.warn("[mpa] identity prewarm unavailable", prewarm.status); + } + } catch (error) { + if (signal?.aborted) throw error; + console.warn("[mpa] identity prewarm unavailable"); + } + } let res: Response; const firstEventDeadline = runSseFirstEventDeadline(signal); try { - res = await apiFetch( - "/run_sse", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - app_name: app, - user_id: userId, - session_id: sessionId, - new_message: { role: "user", parts }, - streaming: true, - ...(platformTools !== undefined - ? { platform_tools: [...platformTools] } - : {}), - ...(environmentMounts !== undefined - ? { environment_mounts: [...environmentMounts] } - : environmentMount - ? { environment_mount: environmentMount } + if (isMpa) { + const runRes = await apiFetch( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/run`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(normalizedIdempotencyKey + ? { "Idempotency-Key": normalizedIdempotencyKey } : {}), - custom_metadata: invocationMetadata - ? { veadkInvocation: invocationMetadata } - : undefined, - }), - signal: firstEventDeadline.signal, - }, - ep, - 0, - ); + }, + body: JSON.stringify({ + content: text, + ...(hasExecutionConfigVersion ? { executionConfigVersion } : {}), + }), + signal: firstEventDeadline.signal, + }, + ep, + 0, + ); + const runtimeContext = runtimeContextFromResponse( + runRes, + ep.runtimeId ?? "", + ep.region ?? "", + ); + if (runtimeContext) onRuntimeContext?.(runtimeContext); + if (!runRes.ok) { + firstEventDeadline.cleanup(); + const detail = await httpErrorMessage(runRes, adkT("client.runSessionFailed")); + throw new Error( + formatRunSseError(adkT("client.runSseFailedWithDetail", { + status: runRes.status, + detail, + })), + ); + } + const accepted = (await runRes.json()) as { + invocationId?: string; + }; + res = await apiFetch( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/sse`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + invocationId: accepted.invocationId, + ...(normalizedLastEventId ? { lastEventId: normalizedLastEventId } : {}), + }), + signal: firstEventDeadline.signal, + }, + ep, + 0, + ); + } else { + res = await apiFetch( + "/run_sse", + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(normalizedIdempotencyKey + ? { "Idempotency-Key": normalizedIdempotencyKey } + : {}), + }, + body: JSON.stringify({ + app_name: app, + user_id: userId, + session_id: sessionId, + new_message: { role: "user", parts }, + streaming: true, + ...(normalizedLastEventId ? { lastEventId: normalizedLastEventId } : {}), + ...(hasExecutionConfigVersion ? { executionConfigVersion } : {}), + ...(modelId?.trim() ? { model_id: modelId.trim() } : {}), + ...(platformTools !== undefined + ? { platform_tools: [...platformTools] } + : {}), + ...(environmentMounts !== undefined + ? { environment_mounts: [...environmentMounts] } + : environmentMount + ? { environment_mount: environmentMount } + : {}), + custom_metadata: customMetadata, + }), + signal: firstEventDeadline.signal, + }, + ep, + 0, + ); + } } catch (error) { firstEventDeadline.cleanup(); if (firstEventDeadline.timedOut()) throw new Error(runSseFirstEventTimeoutError()); @@ -1919,9 +2511,18 @@ export async function* runSSE({ let receivedEvent = false; try { for await (const evt of parseSSE(res)) { + if ( + evt && + typeof evt === "object" && + (evt as Record)[SSE_EVENT_NAME] === "done" + ) { + firstEventDeadline.clearDeadline(); + break; + } receivedEvent = true; firstEventDeadline.clearDeadline(); - const event = evt as AdkEvent; + const event = { ...(evt as AdkEvent) }; + delete (event as Record)[SSE_EVENT_NAME]; if (typeof event.error === "string") event.error = formatRunSseError(event.error); if (typeof event.errorMessage === "string") { event.errorMessage = formatRunSseError(event.errorMessage); @@ -1947,6 +2548,7 @@ export interface DeployAgentkitResult { agentName: string; runtimeName: string; runtimeId?: string; + mpaInstanceId?: string; consoleUrl?: string; region?: string; version?: number | null; @@ -1956,6 +2558,628 @@ export interface DeployAgentkitResult { transport: string; runtimeId?: string; }; + mpaOperation?: MpaAgentOperation; +} + +export interface MpaProfilePayload { + name: string; + description: string; + system: string; + model: Record; + tools: Array>; + skills: Array>; + mcpServers: Array>; + multiagent?: Record; + metadata: Record; +} + +export interface MpaAgentOperation { + operationId: string; + operationKind: "create" | "update"; + ownerId: string; + targetKey: string; + stage: string; + status: "active" | "succeeded" | "failed_retryable" | "failed_terminal" | "cancelled"; + mpaInstanceId?: string; + runtimeId?: string; + runtimeRegion?: string; + runtimeRevision?: string; + profileRevision?: number | null; + profileDigest?: string; + profileOperationId?: string; + safeErrorCode?: string; + retryCount?: number; + createdAt?: string; + updatedAt?: string; +} + +export interface MpaProfileStatus { + operationId: string; + status: string; + profileRevision: number; + runtimeRevision: string; + etag: string; +} + +export type MpaAgentBindingStatus = + | "runtime_missing" + | "bound" + | "binding_ambiguous" + | "orphan_runtime"; + +export interface MpaAgentView { + mpaInstanceId: string; + bindingStatus: MpaAgentBindingStatus; + runtime: (Partial & { + runtimeId: string; + region: string; + readinessPhase?: string; + }) | null; + profile: MpaProfileStatus | null; + activeOperation: MpaAgentOperation | null; + bindings: Array & { runtimeId: string; region: string }>; + capabilities: { + canRead: boolean; + canWrite: boolean; + canDebug: boolean; + }; + safeError?: { code: string; message: string } | null; +} + +export interface MpaAgentDeletePreview { + mpaInstanceId: string; + bindingStatus: MpaAgentBindingStatus; + runtime: (Partial & { runtimeId: string; region: string }) | null; + profile: Partial | null; + activeOperation: MpaAgentOperation | null; + bindings: Array & { runtimeId: string; region: string }>; + canDelete: boolean; + blockers: string[]; + sessionCounts: { + total: number; + active: number; + idle: number; + debug: number; + }; + activeSessions: Array<{ + sessionId: string; + status: string; + appName?: string; + }>; + cleanupPlan: Array<{ + stage: string; + description: string; + count: number; + }>; +} + +export interface MpaRuntimeConsoleRun { + invocationId: string; + userId: string; + status: string; + startedAt: string; + endedAt?: string | null; + durationMs?: number | null; + wallDurationMs?: number | null; + stepCount: number; + queueDurationMs?: number | null; + firstContentMs?: number | null; + firstModelContentMs?: number | null; + firstVisibleAnswerMs?: number | null; + modelRequestCount: number; + toolCount: number; + failedToolCount: number; + fileChangeCount: number; + fileChangeEventCount: number; + uniqueFileCount: number; + retryCount: number; + usage: Record; + slowestStep?: Record | null; + metricCoverage: Record; +} + +export interface MpaRuntimeConsoleRunsResponse { + sessionId: string; + runs: MpaRuntimeConsoleRun[]; +} + +export interface MpaRuntimeTraceStep { + stepId: string; + parentStepId?: string | null; + sequence: number; + kind: string; + title: string; + status: string; + startedAt: string; + endedAt?: string | null; + durationMs?: number | null; + sourceEventId?: string | null; + category: string; + timing: Record; + correlation: Record; + details: Record; +} + +export interface MpaRuntimeTraceResponse { + traceVersion: string; + sessionId: string; + invocationId: string; + correlation: Record; + run: Record; + coverage: Record; + steps: MpaRuntimeTraceStep[]; +} + +export type MpaExecutionConfigMode = "replace" | "clear" | "inherit"; + +export interface MpaExecutionConfigChange { + category: string; + mode: MpaExecutionConfigMode; + value?: unknown; +} + +export interface MpaSessionExecutionConfig { + appName: string; + sessionId: string; + revision: number; + etag: string; + mpaInstanceId: string; + profileRevision: number; + profileDefaultRevision: number; + overrides: Record; + effectiveRefs: Record; + invalidRefs: Array>; + updatedBy?: string; + createdAt?: string; + updatedAt?: string; +} + +export class MpaExecutionConfigRequestError extends Error { + readonly status: number; + readonly code: string; + readonly currentState?: MpaSessionExecutionConfig; + + constructor( + message: string, + { + status, + code, + currentState, + }: { + status: number; + code: string; + currentState?: MpaSessionExecutionConfig; + }, + ) { + super(message); + this.name = "MpaExecutionConfigRequestError"; + this.status = status; + this.code = code; + this.currentState = currentState; + } +} + +async function mpaExecutionConfigError( + res: Response, +): Promise { + const fallback = adkT("client.mpaExecutionConfigFailed"); + const text = await res.text().catch(() => ""); + if (!text) { + return new MpaExecutionConfigRequestError( + adkT("common.fallbackWithHttpStatus", { fallback, status: res.status }), + { status: res.status, code: "" }, + ); + } + try { + const data = JSON.parse(text) as { + detail?: unknown; + error?: unknown; + currentState?: MpaSessionExecutionConfig; + }; + const envelope = ( + data.error && typeof data.error === "object" + ? data.error + : data.detail && typeof data.detail === "object" + ? data.detail + : null + ) as { code?: unknown; currentState?: MpaSessionExecutionConfig } | null; + const detail = formatErrorDetail(data.detail ?? data.error); + const message = detail + ? adkT("client.errorWithDetailAndRawResponse", { + context: adkT("common.fallbackWithHttpStatus", { + fallback, + status: res.status, + }), + detail, + response: text, + }) + : adkT("client.errorWithRawResponse", { + context: adkT("common.fallbackWithHttpStatus", { + fallback, + status: res.status, + }), + response: text, + }); + return new MpaExecutionConfigRequestError(message, { + status: res.status, + code: String(envelope?.code ?? ""), + currentState: envelope?.currentState ?? data.currentState, + }); + } catch { + return new MpaExecutionConfigRequestError( + adkT("client.errorWithRawResponse", { + context: adkT("common.fallbackWithHttpStatus", { + fallback, + status: res.status, + }), + response: text, + }), + { status: res.status, code: "" }, + ); + } +} + +export function mpaProfileFromAgentDraft(draft: AgentDraft): MpaProfilePayload { + const selectedSkillProfiles = (draft.selectedSkills ?? []).map((skill) => ({ + source: skill.source, + folder: skill.folder, + name: skill.name, + skillSpaceId: skill.skillSpaceId, + skillSpaceName: skill.skillSpaceName, + skillSpaceRegion: skill.skillSpaceRegion, + skillId: skill.skillId, + version: skill.version, + })); + const legacySkills = (draft.skills ?? []).map((name) => ({ name })); + const mcpServers = (draft.mcpTools ?? []).map((tool) => ({ + name: tool.name, + transport: tool.transport, + url: tool.url, + })); + return { + name: draft.name?.trim() || "mpa-agent", + description: draft.description ?? "", + system: draft.instruction?.trim() || "You are a helpful assistant.", + model: { + id: draft.modelName || draft.model || "", + source: draft.modelSource ?? "ark", + provider: draft.modelProvider ?? "", + apiBase: draft.modelApiBase ?? "", + }, + tools: [ + ...(draft.builtinTools ?? []).map((id) => ({ id, source: "builtin" })), + ...(draft.tools ?? []).map((name) => ({ name, source: "legacy" })), + ...(draft.customTools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description, + source: "custom", + })), + ], + skills: [...selectedSkillProfiles, ...legacySkills], + mcpServers, + ...(draft.subAgents?.length + ? { multiagent: { type: draft.agentType ?? "llm", subAgents: draft.subAgents.map(mpaProfileFromAgentDraft) } } + : {}), + metadata: { + "veadk:agent-type": "mpa", + "veadk:source": "studio-agent-draft", + agentType: draft.agentType ?? "llm", + }, + }; +} + +export async function startMpaAgentOperation(params: { + operationKind: "create" | "update"; + runtimeId: string; + region: string; + mpaInstanceId: string; + sourceProfileId: string; + draft: AgentDraft; + targetKey?: string; + runtimeRevision?: string; + idempotencyKey: string; +}): Promise { + const body = { + operationKind: params.operationKind, + runtimeId: params.runtimeId, + region: params.region, + mpaInstanceId: params.mpaInstanceId, + sourceProfileId: params.sourceProfileId, + targetKey: params.targetKey, + runtimeRevision: params.runtimeRevision, + profile: mpaProfileFromAgentDraft(params.draft), + }; + const path = + params.operationKind === "update" + ? `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}` + : "/web/mpa/agents"; + const res = await apiFetch(path, { + method: params.operationKind === "update" ? "PATCH" : "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": params.idempotencyKey, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationFailed"))); + } + return (await res.json()) as MpaAgentOperation; +} + +export async function applyMpaProfileAfterDeployment(params: { + draft: AgentDraft; + taskId: string; + agentName: string; + runtimeId: string; + region: string; + operationKind: "create" | "update"; + mpaInstanceId?: string; + runtimeName?: string; + sourceDraftId?: string; + runtimeRevision?: string; +}): Promise { + const normalizedRuntimeId = params.runtimeId.trim(); + const normalizedMpaInstanceId = + params.mpaInstanceId?.trim() || normalizedRuntimeId; + const sourceProfileId = params.sourceDraftId?.trim() + ? `studio-draft:${params.sourceDraftId.trim()}` + : `studio-agent:${params.agentName}:${params.taskId}`; + return startMpaAgentOperation({ + operationKind: params.operationKind, + runtimeId: normalizedRuntimeId, + region: params.region, + mpaInstanceId: normalizedMpaInstanceId, + sourceProfileId, + draft: params.draft, + targetKey: + params.operationKind === "update" + ? normalizedMpaInstanceId + : `runtime:${params.region}:${params.runtimeName?.trim() || normalizedMpaInstanceId}`, + runtimeRevision: params.runtimeRevision?.trim() || undefined, + idempotencyKey: `${params.taskId}:mpa-profile`, + }); +} + +export async function listActiveMpaAgentOperations(): Promise { + const res = await apiFetch("/web/mpa/agent-operations?status=active", { + cache: "no-store", + }); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed"))); + } + const payload = (await res.json()) as { operations?: MpaAgentOperation[] }; + return payload.operations ?? []; +} + +export async function getMpaAgentOperation( + operationId: string, + signal?: AbortSignal, +): Promise { + const res = await apiFetch( + `/web/mpa/agent-operations/${encodeURIComponent(operationId)}`, + { cache: "no-store", signal }, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed"))); + } + return (await res.json()) as MpaAgentOperation; +} + +export async function getMpaProfileStatus(params: { + runtimeId: string; + region: string; + mpaInstanceId: string; + signal?: AbortSignal; +}): Promise { + const query = new URLSearchParams({ + runtimeId: params.runtimeId, + region: params.region, + }); + const res = await apiFetch( + `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}/profile-status?${query.toString()}`, + { cache: "no-store", signal: params.signal }, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed"))); + } + const payload = (await res.json()) as Partial; + return { + operationId: String(payload.operationId ?? ""), + status: String(payload.status ?? ""), + profileRevision: Number(payload.profileRevision ?? 0), + runtimeRevision: String(payload.runtimeRevision ?? ""), + etag: String(payload.etag ?? res.headers.get("ETag") ?? ""), + }; +} + +export async function getMpaAgentView(params: { + mpaInstanceId: string; + runtimeId?: string; + region?: string; + signal?: AbortSignal; +}): Promise { + const query = new URLSearchParams({ region: params.region ?? "all" }); + if (params.runtimeId) query.set("runtimeId", params.runtimeId); + const res = await apiFetch( + `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}/view?${query.toString()}`, + { cache: "no-store", signal: params.signal }, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationLoadFailed"))); + } + return (await res.json()) as MpaAgentView; +} + +export async function getMpaAgentDeletePreview(params: { + mpaInstanceId: string; + runtimeId: string; + region: string; + signal?: AbortSignal; +}): Promise { + const query = new URLSearchParams({ + runtimeId: params.runtimeId, + region: params.region, + }); + const res = await apiFetch( + `/web/mpa/agents/${encodeURIComponent(params.mpaInstanceId)}/delete-preview?${query.toString()}`, + { cache: "no-store", signal: params.signal }, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.mpaDeletePreviewFailed"))); + } + return (await res.json()) as MpaAgentDeletePreview; +} + +export async function getMpaRuntimeConsoleRuns(params: { + runtimeId: string; + region: string; + sessionId: string; + signal?: AbortSignal; +}): Promise { + const ep: AdkEndpoint = { + runtimeId: params.runtimeId, + region: params.region, + agentCategory: "mpa", + }; + const res = await apiFetch( + `/api/v1/runtime-console/admin/sessions/${encodeURIComponent(params.sessionId)}/runs`, + { cache: "no-store", signal: params.signal }, + ep, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.loadMpaDiagnosticsFailed"))); + } + return (await res.json()) as MpaRuntimeConsoleRunsResponse; +} + +export async function getMpaRuntimeConsoleTrace(params: { + runtimeId: string; + region: string; + sessionId: string; + invocationId: string; + signal?: AbortSignal; +}): Promise { + const ep: AdkEndpoint = { + runtimeId: params.runtimeId, + region: params.region, + agentCategory: "mpa", + }; + const query = new URLSearchParams({ sessionId: params.sessionId }); + const res = await apiFetch( + `/api/v1/runtime-console/admin/runs/${encodeURIComponent(params.invocationId)}/trace?${query.toString()}`, + { cache: "no-store", signal: params.signal }, + ep, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.loadMpaDiagnosticsFailed"))); + } + return (await res.json()) as MpaRuntimeTraceResponse; +} + +export async function retryMpaAgentOperation( + operationId: string, + params: Omit[0], "idempotencyKey">, +): Promise { + const res = await apiFetch( + `/web/mpa/agent-operations/${encodeURIComponent(operationId)}/retry`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + operationKind: params.operationKind, + runtimeId: params.runtimeId, + region: params.region, + mpaInstanceId: params.mpaInstanceId, + sourceProfileId: params.sourceProfileId, + targetKey: params.targetKey, + runtimeRevision: params.runtimeRevision, + profile: mpaProfileFromAgentDraft(params.draft), + }), + }, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, adkT("client.mpaOperationFailed"))); + } + return (await res.json()) as MpaAgentOperation; +} + +export async function getMpaSessionExecutionConfig(params: { + runtimeId: string; + region: string; + sessionId: string; + signal?: AbortSignal; +}): Promise { + const query = new URLSearchParams({ + runtimeId: params.runtimeId, + region: params.region, + }); + const res = await apiFetch( + `/web/mpa/sessions/${encodeURIComponent(params.sessionId)}/execution-config?${query.toString()}`, + { cache: "no-store", signal: params.signal }, + ); + if (!res.ok) { + throw await mpaExecutionConfigError(res); + } + return (await res.json()) as MpaSessionExecutionConfig; +} + +export async function patchMpaSessionExecutionConfig(params: { + runtimeId: string; + region: string; + sessionId: string; + etag: string; + changes: MpaExecutionConfigChange[]; +}): Promise { + const res = await apiFetch( + `/web/mpa/sessions/${encodeURIComponent(params.sessionId)}/execution-config`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "If-Match": params.etag, + }, + body: JSON.stringify({ + runtimeId: params.runtimeId, + region: params.region, + changes: params.changes, + }), + }, + ); + if (!res.ok) { + throw await mpaExecutionConfigError(res); + } + return (await res.json()) as MpaSessionExecutionConfig; +} + +export async function upgradeMpaSessionProfile(params: { + runtimeId: string; + region: string; + sessionId: string; + etag: string; + idempotencyKey: string; + targetProfileRevision: number; +}): Promise { + const res = await apiFetch( + `/web/mpa/sessions/${encodeURIComponent(params.sessionId)}/profile-upgrade`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "If-Match": params.etag, + "Idempotency-Key": params.idempotencyKey, + }, + body: JSON.stringify({ + runtimeId: params.runtimeId, + region: params.region, + targetProfileRevision: params.targetProfileRevision, + }), + }, + ); + if (!res.ok) { + throw await mpaExecutionConfigError(res); + } + return (await res.json()) as MpaSessionExecutionConfig; } export async function checkRuntimeNameAvailability( @@ -3343,6 +4567,8 @@ export async function createGithubDeliveryCicdPipeline(params: { runtimeName: string; runtimeId: string; region: string; + agentCategory?: "general" | "mpa"; + mpaCompatibilityManifest?: Record; cloudProvider: CloudProvider; projectPath?: string; volcengineAccessKey: string; @@ -3361,6 +4587,8 @@ export async function createGithubDeliveryCicdPipeline(params: { runtimeName: params.runtimeName, runtimeId: params.runtimeId, region: params.region, + agentCategory: params.agentCategory, + mpaCompatibilityManifest: params.mpaCompatibilityManifest, cloudProvider: params.cloudProvider, projectPath: params.projectPath ?? ".", volcengineAccessKey: params.volcengineAccessKey, @@ -3383,6 +4611,8 @@ export async function initializeGithubDeliveryMain(params: { runtimeName: string; runtimeId: string; region: string; + agentCategory?: "general" | "mpa"; + mpaCompatibilityManifest?: Record; cloudProvider: CloudProvider; projectPath?: string; volcengineAccessKey: string; @@ -3402,6 +4632,8 @@ export async function initializeGithubDeliveryMain(params: { runtimeName: params.runtimeName, runtimeId: params.runtimeId, region: params.region, + agentCategory: params.agentCategory, + mpaCompatibilityManifest: params.mpaCompatibilityManifest, cloudProvider: params.cloudProvider, projectPath: params.projectPath ?? ".", volcengineAccessKey: params.volcengineAccessKey, @@ -3421,6 +4653,8 @@ export async function attachGithubDeliveryCicdToSourceSync(params: { runtimeName: string; runtimeId: string; region: string; + agentCategory?: "general" | "mpa"; + mpaCompatibilityManifest?: Record; cloudProvider: CloudProvider; projectPath?: string; volcengineAccessKey: string; @@ -3437,6 +4671,8 @@ export async function attachGithubDeliveryCicdToSourceSync(params: { runtimeName: params.runtimeName, runtimeId: params.runtimeId, region: params.region, + agentCategory: params.agentCategory, + mpaCompatibilityManifest: params.mpaCompatibilityManifest, cloudProvider: params.cloudProvider, projectPath: params.projectPath ?? ".", volcengineAccessKey: params.volcengineAccessKey, @@ -3475,6 +4711,9 @@ export async function getGithubDeliveryVersions( export async function createGithubDeliveryRollbackPr(params: { runtimeId: string; targetCommitSha: string; + region?: string; + agentCategory?: "general" | "mpa"; + mpaCompatibilityManifest?: Record; }): Promise { const res = await apiFetch("/web/github-delivery/rollback-pr", { method: "POST", @@ -3503,6 +4742,8 @@ export async function bindGithubCicdRuntime(params: { export async function syncGithubCicdRuntime(params: { runtimeId: string; project: { name: string; files: { path: string; content: string }[] }; + agentCategory?: "general" | "mpa"; + mpaCompatibilityManifest?: Record; }): Promise { const res = await apiFetch( "/web/github-cicd/runtime-sync", @@ -3512,6 +4753,8 @@ export async function syncGithubCicdRuntime(params: { body: JSON.stringify({ runtimeId: params.runtimeId, project: params.project, + agentCategory: params.agentCategory, + mpaCompatibilityManifest: params.mpaCompatibilityManifest, }), }, {}, @@ -3543,6 +4786,8 @@ export async function deployAgentkitProject( runtimeId?: string; runtimeName?: string; appName?: string; + agentCategory?: "general" | "mpa"; + mpaCompatibilityManifest?: Record; editMode?: "source-preserving" | "regenerate"; draft?: AgentDraft; updateEtag?: string; @@ -3667,6 +4912,8 @@ export async function deployAgentkitProject( runtimeId: opts?.runtimeId, runtimeName: opts?.runtimeName, appName: opts?.appName, + agentCategory: opts?.agentCategory, + mpaCompatibilityManifest: opts?.mpaCompatibilityManifest, editMode: opts?.editMode, draft: opts?.draft, updateEtag: opts?.updateEtag, @@ -3768,6 +5015,7 @@ export async function deployAgentkitProject( agentName: deployedAgentName, runtimeName: deployedRuntimeName, runtimeId: final.runtimeId, + mpaInstanceId: final.mpaInstanceId?.trim() || undefined, consoleUrl: final.consoleUrl, region: final.region, version: final.version, @@ -4196,6 +5444,7 @@ export async function getAgentUsage({ export interface CloudRuntime { name: string; runtimeId: string; + mpaInstanceId?: string; status: string; region: string; author: string; @@ -4204,6 +5453,8 @@ export interface CloudRuntime { memoryMb?: number | null; createdAt?: string; currentVersion?: number | null; + /** Product family used by the new-chat picker. */ + agentCategory?: "general" | "mpa"; /** True when this runtime was deployed by the current user (veadk:author). */ isMine: boolean; /** Server-authorized deletion capability for this managed Runtime. */ @@ -4417,6 +5668,7 @@ export class RuntimeListError extends Error { * page continues pagination; the server derives ownership from identity. */ export async function getRuntimes( opts: { + agentCategory?: "general" | "mpa"; nextToken?: string; pageSize?: number; region?: string; @@ -4429,6 +5681,7 @@ export async function getRuntimes( page_size: String(opts.pageSize ?? 30), region: opts.region ?? "all", }); + if (opts.agentCategory) p.set("agentCategory", opts.agentCategory); if (opts.nextToken) p.set("next_token", opts.nextToken); const res = await apiFetch(`/web/runtimes?${p.toString()}`, { signal: opts.signal, @@ -4625,11 +5878,17 @@ export async function revealRuntimeApiKey( export async function deleteRuntime( runtimeId: string, region: string, + opts: { agentCategory?: "general" | "mpa"; mpaInstanceId?: string } = {}, ): Promise { const res = await apiFetch("/web/delete-runtime", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ runtimeId, region }), + body: JSON.stringify({ + runtimeId, + region, + agentCategory: opts.agentCategory, + mpaInstanceId: opts.mpaInstanceId, + }), }); if (!res.ok) { const t = await res.text().catch(() => ""); @@ -4663,6 +5922,7 @@ export interface RuntimeUpdateCapability { etag: string; runtime: { runtimeId: string; + mpaInstanceId?: string; name: string; region: string; currentVersion?: number | null; @@ -4955,6 +6215,7 @@ async function runtimeUpdateCapabilityErrorMessage(res: Response): Promise { + const params = new URLSearchParams({ runtimeId, region, key }); + const response = await apiFetch(`/web/runtime-env/copy?${params.toString()}`, { method: "POST" }); + if (!response.ok) throw new Error(await httpErrorMessage(response, adkT("client.copySecretFailed"))); + const payload = await response.json() as { value?: unknown }; + if (typeof payload.value !== "string" || !payload.value) { + throw new Error(adkT("client.copySecretFailed")); + } + await navigator.clipboard.writeText(payload.value); +} + export function getCachedRuntimeDetail( runtimeId: string, region = "cn-beijing", @@ -5286,3 +6563,36 @@ export async function updateSandboxTool(kind: SandboxToolKind): Promise<{ if (typeof payload.updated !== "boolean") throw new Error(adkT("client.invalidSandboxUpdate")); return { updated: payload.updated, state: sandboxImageState(payload.state) }; } + + +export type ChannelScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; +export interface ChannelCapabilities { channels?: string[]; credentialBindingChannels?: string[]; serverSideBinding: boolean; bindingReady: boolean; bindingError?: string | null; } +export interface FeishuBinding { + id: string; + status: "PENDING" | "SCANNED" | "AUTHORIZED" | "REGISTERING" | "BOUND" | "FAILED" | "EXPIRED"; + loginUrl: string; qrCodeImage: string; expiresAt: string; pollAfterSeconds: number; + appId?: string | null; lastErrorCode?: string | null; lastErrorMessage?: string | null; +} +export interface ChannelDiagnostics { + configured: boolean; appId?: string | null; appName?: string | null; + gatewayConfigured: boolean; routeConfigured: boolean; missingConfiguration: string[]; + deliveryHealth: string; lastInboundAt?: number | null; lastDeliveryAt?: number | null; +} +export interface ChannelPermission { channel: string; chatId: string; chatName?: string; groupSessionScope?: ChannelScope | null; } +export class ChannelApiError extends Error { + constructor(public status: number) { super(`Channel request failed (${status})`); } +} +export async function channelRequest(ep: AdkEndpoint, path: string, init: RequestInit = {}): Promise { + const response = await apiFetch(`/api/v1/channels${path}`, { + ...init, headers: { "Content-Type": "application/json", ...init.headers }, + }, ep); + // Do not surface arbitrary upstream error bodies: they may contain secrets. + if (!response.ok) throw new ChannelApiError(response.status); + return response.json() as Promise; +} +export function listMpaCronTasks(runtime: MpaRuntime, offset: number, query: string, signal?: AbortSignal) { + return fetchMpaCronTasks(apiFetch, runtime, offset, query, signal); +} + +export function requestMpaTask(runtime: MpaRuntime, method: string, suffix: string, payload?: unknown, signal?: AbortSignal) { return manageMpaTask(apiFetch, runtime, method, suffix, payload, signal); } +export function listMpaRuns(runtime: MpaRuntime, taskId: string, offset: number, signal?: AbortSignal) { return fetchMpaRuns(apiFetch, runtime, taskId, offset, signal); } diff --git a/frontend/src/adk/connections.ts b/frontend/src/adk/connections.ts index 08491d4b3..893162249 100644 --- a/frontend/src/adk/connections.ts +++ b/frontend/src/adk/connections.ts @@ -23,8 +23,10 @@ export interface RemoteConnection { /** Preferred: an AgentKit runtime routed through the server-side proxy. When * set, `base`/`apiKey` are unused and the apikey stays server-side. */ runtimeId?: string; + mpaInstanceId?: string; region?: string; currentVersion?: number | null; + agentCategory?: "general" | "mpa"; apps: string[]; /** Optional app ID -> friendly name mapping (e.g., "a_1" -> "a_1-4zkzsezc") */ appLabels?: Record; @@ -39,10 +41,13 @@ export interface AgentEntry { remote: boolean; host?: string; // remote host, for display runtimeId?: string; + mpaInstanceId?: string; region?: string; currentVersion?: number | null; /** Server-authorized permission for Studio-managed Runtime deletion. */ canDelete?: boolean; + /** Product category supplied by the Runtime list; unknown entries hide MPA-only UI. */ + agentCategory?: "general" | "mpa"; } const STORAGE_KEY = "veadk_agentkit_connections"; @@ -53,6 +58,8 @@ interface ConnectRuntimeOptions { waitForReady?: boolean; /** Friendly ADK Agent label. Runtime resource names may include a suffix. */ agentName?: string; + agentCategory?: "general" | "mpa"; + mpaInstanceId?: string; } export function loadConnections(): RemoteConnection[] { @@ -95,7 +102,13 @@ export function registerConnections(conns: RemoteConnection[]): void { registerRemoteApp( remoteAppId(c.id, app), c.runtimeId - ? { app, runtimeId: c.runtimeId, region: c.region! } + ? { + app, + runtimeId: c.runtimeId, + mpaInstanceId: c.mpaInstanceId, + region: c.region!, + agentCategory: c.agentCategory, + } : { app, base: c.base, apiKey: c.apiKey }, ); } @@ -113,7 +126,12 @@ export function addRuntimeConnection( apps: string[], appLabels?: Record, currentVersion?: number | null, + agentCategory?: "general" | "mpa", + mpaInstanceId?: string, ): RemoteConnection { + const list = loadConnections(); + const existingIndex = list.findIndex((item) => item.runtimeId === runtimeId); + const previous = existingIndex === -1 ? undefined : list[existingIndex]; const conn: RemoteConnection = { id: `rt_${runtimeId}`, name: name || runtimeId, @@ -122,9 +140,9 @@ export function addRuntimeConnection( apps, appLabels, currentVersion, + agentCategory: agentCategory ?? previous?.agentCategory, + mpaInstanceId: mpaInstanceId?.trim() || previous?.mpaInstanceId, }; - const list = loadConnections(); - const existingIndex = list.findIndex((item) => item.runtimeId === runtimeId); if (existingIndex === -1) list.push(conn); else list[existingIndex] = conn; persist(list); @@ -138,6 +156,8 @@ async function connectRuntimeOnce( region: string, currentVersion?: number | null, agentName?: string, + agentCategory?: "general" | "mpa", + mpaInstanceId?: string, ): Promise { let apps: string[] | null = null; let resolvedRegion = region || "cn-beijing"; @@ -187,6 +207,8 @@ async function connectRuntimeOnce( apps, labels, currentVersion, + agentCategory, + mpaInstanceId, ); return remoteAppId(connection.id, apps[0]); } @@ -212,6 +234,8 @@ export async function connectRuntime( region, currentVersion, options.agentName, + options.agentCategory, + options.mpaInstanceId, ); } catch (error) { const elapsedMs = Date.now() - startedAt; @@ -293,6 +317,8 @@ export function buildAgentEntries( runtimeId: c.runtimeId, region: c.region, currentVersion: c.currentVersion, + agentCategory: c.agentCategory, + mpaInstanceId: c.mpaInstanceId, }; }), ); diff --git a/frontend/src/adk/deploymentI18n.ts b/frontend/src/adk/deploymentI18n.ts index 61388b2b0..0973d9ff4 100644 --- a/frontend/src/adk/deploymentI18n.ts +++ b/frontend/src/adk/deploymentI18n.ts @@ -27,6 +27,9 @@ const PHASE_KEYS: Record = { publish: "client.deploymentProgress.publishing", evaluation: "client.deploymentProgress.evaluating", update: "client.deploymentProgress.updating", + profile_applying: "client.deploymentProgress.applyingMpaProfile", + smoke_running: "client.deploymentProgress.runningMpaSmoke", + runnable: "client.deploymentProgress.mpaReady", complete: "client.deploymentProgress.completing", github: "client.deploymentProgress.github", }; diff --git a/frontend/src/adk/mpa-identity-prewarm.test.ts b/frontend/src/adk/mpa-identity-prewarm.test.ts new file mode 100644 index 000000000..05f8978f2 --- /dev/null +++ b/frontend/src/adk/mpa-identity-prewarm.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { clearRemoteApps, registerRemoteApp, runSSE } from "./client"; + +beforeEach(() => { + vi.stubGlobal("window", { location: { search: "", origin: "https://studio.example" } }); + vi.stubGlobal("sessionStorage", { getItem: () => null }); + vi.stubGlobal("localStorage", { getItem: () => null }); +}); + +afterEach(() => { + clearRemoteApps(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const run = () => runSSE({ + appName: "remote-mpa", + userId: "studio-user", + sessionId: "session-1", + text: "hello", +}); + +it("prewarms an MPA Runtime before sending a web-chat run", async () => { + registerRemoteApp("remote-mpa", { + app: "mpa-agent", + runtimeId: "runtime-1", + region: "cn-beijing", + agentCategory: "mpa", + }); + const fetcher = vi.fn() + .mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 })) + .mockResolvedValueOnce(new Response('{"invocationId":"inv-1"}', { status: 200 })) + .mockResolvedValueOnce(new Response('data: {"author":"agent"}\n\nevent: done\ndata: {}\n\n', { status: 200 })); + vi.stubGlobal("fetch", fetcher); + + const events = []; + for await (const event of run()) events.push(event); + + expect(events).toHaveLength(1); + expect(String(fetcher.mock.calls[0][0])).toContain("/web/mpa/identity-prewarm/runtime-1?region=cn-beijing"); + expect(fetcher.mock.calls[0][1].method).toBe("POST"); + expect(new Headers(fetcher.mock.calls[0][1].headers).get("X-Requested-With")).toBe("XMLHttpRequest"); + expect(String(fetcher.mock.calls[1][0])).toContain("/web/runtime-proxy/runtime-1/api/v1/sessions/session-1/run"); + expect(JSON.stringify(fetcher.mock.calls)).not.toContain("idToken"); +}); + +it("keeps MPA chat available when identity prewarm fails", async () => { + registerRemoteApp("remote-mpa", { + app: "mpa-agent", runtimeId: "runtime-1", agentCategory: "mpa", + }); + const fetcher = vi.fn() + .mockResolvedValueOnce(new Response( + '{"detail":"studio_oauth_session_required"}', + { status: 409, headers: { "Content-Type": "application/json" } }, + )) + .mockResolvedValueOnce(new Response('{"invocationId":"inv-1"}', { status: 200 })) + .mockResolvedValueOnce(new Response('data: {"author":"agent"}\n\nevent: done\ndata: {}\n\n', { status: 200 })); + vi.stubGlobal("fetch", fetcher); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const events = []; + for await (const event of run()) events.push(event); + + expect(events).toHaveLength(1); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(String(fetcher.mock.calls[1][0])).toContain("/run"); + expect(warning).toHaveBeenCalledWith("[mpa] identity prewarm unavailable", 409); + warning.mockRestore(); +}); + +it("keeps MPA chat available when the prewarm request has a network error", async () => { + registerRemoteApp("remote-mpa", { + app: "mpa-agent", runtimeId: "runtime-1", agentCategory: "mpa", + }); + const fetcher = vi.fn() + .mockRejectedValueOnce(new Error("network unavailable")) + .mockResolvedValueOnce(new Response('{"invocationId":"inv-1"}', { status: 200 })) + .mockResolvedValueOnce(new Response('data: {"author":"agent"}\n\nevent: done\ndata: {}\n\n', { status: 200 })); + vi.stubGlobal("fetch", fetcher); + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const events = []; + for await (const event of run()) events.push(event); + + expect(events).toHaveLength(1); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(warning).toHaveBeenCalledWith("[mpa] identity prewarm unavailable"); + warning.mockRestore(); +}); + +it("does not start a run when chat is cancelled during prewarm", async () => { + registerRemoteApp("remote-mpa", { + app: "mpa-agent", runtimeId: "runtime-1", agentCategory: "mpa", + }); + const controller = new AbortController(); + const fetcher = vi.fn().mockImplementation(async () => { + controller.abort(); + throw new DOMException("Aborted", "AbortError"); + }); + vi.stubGlobal("fetch", fetcher); + + await expect(runSSE({ + appName: "remote-mpa", userId: "studio-user", sessionId: "session-1", + text: "hello", signal: controller.signal, + }).next()).rejects.toThrow(); + expect(fetcher).toHaveBeenCalledTimes(1); +}); diff --git a/frontend/src/adk/mpaCreation.ts b/frontend/src/adk/mpaCreation.ts new file mode 100644 index 000000000..7f19aee3e --- /dev/null +++ b/frontend/src/adk/mpaCreation.ts @@ -0,0 +1,106 @@ +import { withAuth } from "./auth"; +import { withLocalUser } from "./identity"; +import { withLocaleHeaders } from "./i18n"; +import { requestSignal, DEFAULT_REQUEST_TIMEOUT_MS } from "./timeout"; + +export interface MpaCreationInput { + requestId: string; + name?: string; + agentId?: string; + description: string; + region: string; + runtimeImage?: string; + workerImage?: string; + pgHost?: string; + pgPort?: string; + openvikingUrl?: string; + openvikingResourceId?: string; + tosAccessKey?: string; + tosSecretKey?: string; + tosBucket?: string; +} +export interface MpaCreationRequest extends MpaCreationInput { + openvikingApiKey?: string; +} +export interface MpaCreationTask + extends Omit< + MpaCreationInput, + "tosAccessKey" | "tosSecretKey" | "tosBucket" + > { + taskId: string; + state: "running" | "cancelling" | "succeeded" | "failed" | "cancelled"; + stage: string; + error?: string; + images?: { runtimeImage?: string; workerImage?: string }; + result?: { + runtime_id?: string; + skill_space_id?: string; + gateway_id?: string; + }; +} +export interface MpaCreationConfig { + configured: boolean; + region: string; + error?: string; + runtimeImage?: string; + workerImage?: string; + pgHost?: string; + pgPort?: string; + postgresMode?: "auto"; + postgresMigrationRequired?: boolean; + postgresLayout?: "split-workspaces"; + adminWorkspaceName?: string; + adminDatabaseName?: string; +} + +export class MpaCreationRequestError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "MpaCreationRequestError"; + } +} + +async function request( + path: string, + signal: AbortSignal, + body?: unknown, +): Promise { + const response = await fetch(withAuth(`/web/mpa-creation/${path}`), { + method: body === undefined ? "GET" : "POST", + headers: withLocaleHeaders( + withLocalUser({ "Content-Type": "application/json" }), + ), + body: body === undefined ? undefined : JSON.stringify(body), + signal: requestSignal(signal, DEFAULT_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + const value = await response.json().catch(() => ({})); + throw new MpaCreationRequestError( + response.status, + typeof value.detail === "string" + ? value.detail + : `HTTP ${response.status}`, + ); + } + return response.json() as Promise; +} +export const getMpaCreationConfig = (region: string, signal: AbortSignal) => + request( + `config?region=${encodeURIComponent(region)}`, + signal, + ); +export const startMpaCreation = ( + input: MpaCreationRequest, + signal: AbortSignal, +) => request("tasks", signal, input); +export const getMpaCreation = (id: string, signal: AbortSignal) => + request(`tasks/${encodeURIComponent(id)}`, signal); +export const cancelMpaCreation = (id: string, signal: AbortSignal) => + request( + `tasks/${encodeURIComponent(id)}/cancel`, + signal, + {}, + ); diff --git a/frontend/src/adk/mpaCreationImages.ts b/frontend/src/adk/mpaCreationImages.ts new file mode 100644 index 000000000..54668e860 --- /dev/null +++ b/frontend/src/adk/mpaCreationImages.ts @@ -0,0 +1,10 @@ +export function validCreationImage(value: string = ""): boolean { + const image = value.trim(); + return ( + !image || + (image.length <= 1024 && + /^[A-Za-z0-9._:/@-]+$/.test(image) && + !image.includes("//") && + (!image.includes("@") || /^[^@]+@sha256:[a-fA-F0-9]{64}$/.test(image))) + ); +} diff --git a/frontend/src/adk/mpaCreationResources.ts b/frontend/src/adk/mpaCreationResources.ts new file mode 100644 index 000000000..5ff059313 --- /dev/null +++ b/frontend/src/adk/mpaCreationResources.ts @@ -0,0 +1,38 @@ +export function validPgTarget(host = "", port = ""): boolean { + const selectedHost = host.trim(); + const selectedPort = port.trim(); + return ( + (!selectedHost && !selectedPort) || + (selectedHost.length <= 255 && + /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(selectedHost) && + !selectedHost.includes("..") && + /^[0-9]{1,5}$/.test(selectedPort) && + Number(selectedPort) >= 1 && + Number(selectedPort) <= 65535) + ); +} + +export function validOpenViking(url = "", resourceId = "", apiKey = ""): boolean { + const selectedUrl = url.trim(); + const selectedId = resourceId.trim(); + if (!selectedUrl && !selectedId && !apiKey) return true; + if (!selectedUrl || !selectedId || !apiKey.trim()) return false; + if (selectedUrl.length > 1024 || /\s/.test(selectedUrl)) return false; + const authority = selectedUrl.replace(/^https:\/\//, "").split("/", 1)[0]; + if (authority.includes(":")) return false; + try { + const parsed = new URL(selectedUrl); + return ( + parsed.protocol === "https:" && + !!parsed.hostname && + !parsed.username && + !parsed.password && + !parsed.port && + !parsed.search && + !parsed.hash && + selectedId.length <= 128 && /^ov-[a-zA-Z0-9_-]+$/.test(selectedId) + ); + } catch { + return false; + } +} diff --git a/frontend/src/adk/mpaCronTasks.ts b/frontend/src/adk/mpaCronTasks.ts new file mode 100644 index 000000000..00dfb523f --- /dev/null +++ b/frontend/src/adk/mpaCronTasks.ts @@ -0,0 +1,210 @@ +import type { AdkEndpoint } from "./client"; + +export interface MpaRuntime { + runtimeId: string; + region: string; + name: string; +} +export interface MpaCronTask { + id: string; + name: string; + enabled: boolean; + schedule: Record & { type: string }; + prompt?: string; + agentId?: string; + version?: number; + delivery?: Record; + jitterSeconds?: number; + timeoutSeconds?: number; + lastRunAt?: string | null; + runningAt?: string | null; + createdAt?: string; + nextRunAt?: string | null; + lastRunStatus?: string | null; +} +export interface MpaCronTaskPage { + overview?: { executionCount: number; successRate: number }; + items: MpaCronTask[]; + total: number; + hasMore: boolean; + nextOffset: number | null; +} +type Request = ( + path: string, + init: RequestInit, + endpoint: AdkEndpoint, +) => Promise; + +function validSchedule(schedule: Record): boolean { + if (schedule.timezone != null) { + if (typeof schedule.timezone !== "string") return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: schedule.timezone }); + } catch { + return false; + } + } + if ( + schedule.runAt != null && + (typeof schedule.runAt !== "string" || + !Number.isFinite(Date.parse(schedule.runAt))) + ) + return false; + return [schedule.weekdays, schedule.monthDays].every( + (days) => + days == null || (Array.isArray(days) && days.every(Number.isInteger)), + ); +} + +export async function fetchMpaCronTasks( + request: Request, + runtime: MpaRuntime, + offset: number, + query: string, + signal?: AbortSignal, +): Promise { + const response = await request( + `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}?region=${encodeURIComponent(runtime.region)}&offset=${offset}&query=${encodeURIComponent(query)}`, + { signal }, + {}, + ); + // Do not expose arbitrary upstream bodies, which may contain credentials. + if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`); + let data; + try { + data = await response.json(); + } catch { + throw new Error("MPA_INVALID_RESPONSE"); + } + if ( + !data || + !Array.isArray(data.items) || + !Number.isInteger(data.total) || + data.total < 0 || + typeof data.hasMore !== "boolean" || + (data.hasMore && + (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) || + (data.overview != null && + (!Number.isFinite(data.overview.executionCount) || + !Number.isFinite(data.overview.successRate))) || + !data.items.every( + (item: MpaCronTask) => + item && + typeof item.id === "string" && + typeof item.name === "string" && + typeof item.enabled === "boolean" && + (item.prompt == null || typeof item.prompt === "string") && + item.schedule && + typeof item.schedule.type === "string" && + validSchedule(item.schedule) && + [item.agentId, item.lastRunAt, item.runningAt, item.createdAt].every( + (value) => value == null || typeof value === "string", + ) && + (item.nextRunAt == null || typeof item.nextRunAt === "string") && + (item.lastRunStatus == null || typeof item.lastRunStatus === "string"), + ) + ) { + throw new Error("MPA_INVALID_RESPONSE"); + } + return data; +} + +export interface MpaRun { + id: string; + status: string; + startedAt?: string | null; + scheduledAt: string; + durationMs?: number | null; + errorMessage?: string | null; + sessionId?: string | null; +} +export interface MpaRunPage { + items: MpaRun[]; + total: number; + hasMore: boolean; + nextOffset: number | null; +} +export type TaskFields = Pick & { + agentId?: string; + prompt: string; + delivery?: Record; + jitterSeconds?: number; + timeoutSeconds?: number; +}; +export async function manageMpaTask( + request: Request, + runtime: MpaRuntime, + method: string, + suffix: string, + payload?: unknown, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}${suffix}?region=${encodeURIComponent(runtime.region)}`, + { + method, + signal, + ...(payload === undefined + ? {} + : { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + }, + {}, + ); + if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`); + let data; + try { + data = await response.json(); + } catch { + throw new Error("MPA_INVALID_RESPONSE"); + } + if (!data || typeof data !== "object" || Array.isArray(data)) + throw new Error("MPA_INVALID_RESPONSE"); + return data; +} + +export async function fetchMpaRuns( + request: Request, + runtime: MpaRuntime, + taskId: string, + offset: number, + signal?: AbortSignal, +): Promise { + const response = await request( + `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}/${encodeURIComponent(taskId)}/runs?region=${encodeURIComponent(runtime.region)}&offset=${offset}`, + { signal }, + {}, + ); + if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`); + let data; + try { + data = await response.json(); + } catch { + throw new Error("MPA_INVALID_RESPONSE"); + } + if ( + !data || + !Array.isArray(data.items) || + !Number.isInteger(data.total) || + data.total < 0 || + typeof data.hasMore !== "boolean" || + (data.hasMore && + (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) || + !data.items.every( + (run: MpaRun) => + run && + typeof run.id === "string" && + typeof run.status === "string" && + typeof run.scheduledAt === "string" && + [run.startedAt, run.errorMessage, run.sessionId].every( + (value) => value == null || typeof value === "string", + ) && + (run.durationMs == null || + (Number.isFinite(run.durationMs) && run.durationMs >= 0)), + ) + ) + throw new Error("MPA_INVALID_RESPONSE"); + return data; +} diff --git a/frontend/src/adk/skillDocuments.ts b/frontend/src/adk/skillDocuments.ts new file mode 100644 index 000000000..4f8d9d1ee --- /dev/null +++ b/frontend/src/adk/skillDocuments.ts @@ -0,0 +1,69 @@ +import { withAuth } from "./auth"; +import { withLocalUser } from "./identity"; +import { withLocaleHeaders } from "./i18n"; +import { + requestSignal, + DEFAULT_REQUEST_TIMEOUT_MS, + TRANSFER_REQUEST_TIMEOUT_MS, +} from "./timeout"; +import { skillApiErrorFromResponse } from "./skills"; + +export interface SkillDocumentTarget { + spaceId: string; + skillId: string; + region: string; + name: string; +} +export interface SkillDocument { + content: string; + baseVersion: string; + canUpdate: boolean; +} +async function request( + target: SkillDocumentTarget & { signal?: AbortSignal }, + init: RequestInit, + timeout: number, +): Promise { + const params = new URLSearchParams({ region: target.region }); + const response = await fetch( + withAuth( + `/web/skill-management/spaces/${encodeURIComponent(target.spaceId)}/skills/${encodeURIComponent(target.skillId)}/document?${params}`, + ), + { + ...init, + headers: withLocaleHeaders(withLocalUser(init.headers)), + signal: requestSignal(target.signal, timeout), + }, + ); + if (!response.ok) + throw await skillApiErrorFromResponse( + response, + "SKILL_DOCUMENT_REQUEST_FAILED", + ); + return response.json() as Promise; +} +export function getSkillDocument( + target: SkillDocumentTarget & { signal?: AbortSignal }, +): Promise { + return request(target, {}, DEFAULT_REQUEST_TIMEOUT_MS); +} +export function saveSkillDocument( + target: SkillDocumentTarget & { + content: string; + baseVersion: string; + signal?: AbortSignal; + }, +): Promise<{ version: string }> { + return request( + target, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: target.content, + baseVersion: target.baseVersion, + }), + }, + TRANSFER_REQUEST_TIMEOUT_MS, + ); +} diff --git a/frontend/src/adk/sse.ts b/frontend/src/adk/sse.ts index 8b964ced4..af98fa4bf 100644 --- a/frontend/src/adk/sse.ts +++ b/frontend/src/adk/sse.ts @@ -1,7 +1,12 @@ // Minimal Server-Sent-Events parser for `fetch` response bodies. // // The ADK `/run_sse` endpoint emits `data: \n\n` frames. This async -// generator yields each parsed JSON payload as it arrives. +// generator yields each parsed JSON payload as it arrives. When an upstream +// names the SSE event, the parsed payload includes a non-enumerable +// `__sseEvent` property so callers that care about transport control frames +// can react without changing normal event shape assertions. + +export const SSE_EVENT_NAME = "__sseEvent"; export async function* parseSSE( response: Response, @@ -19,14 +24,26 @@ export async function* parseSSE( }; const parseFrame = (frame: string, final = false): unknown | undefined => { - const data = frame - .split(/\r?\n/) + const lines = frame.split(/\r?\n/); + const eventName = lines + .find((line) => line.startsWith("event:")) + ?.slice(6) + .trim(); + const data = lines .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trimStart()) .join("\n"); if (!data || data === "[DONE]" || data === "ping") return undefined; try { - return JSON.parse(data); + const parsed = JSON.parse(data); + if (eventName && parsed && typeof parsed === "object") { + Object.defineProperty(parsed, SSE_EVENT_NAME, { + value: eventName, + enumerable: false, + configurable: true, + }); + } + return parsed; } catch { const rawData = rawDataExcerpt(data); if (final) { diff --git a/frontend/src/adk/wecom-sdk.d.ts b/frontend/src/adk/wecom-sdk.d.ts new file mode 100644 index 000000000..b0a50c9aa --- /dev/null +++ b/frontend/src/adk/wecom-sdk.d.ts @@ -0,0 +1,3 @@ +declare module "@wecom/wecom-aibot-sdk/dist/wecom-aibot-sdk.esm.js" { + export { default, WecomAIBotSDK } from "@wecom/wecom-aibot-sdk"; +} diff --git a/frontend/src/adk/wecomAuthorization.ts b/frontend/src/adk/wecomAuthorization.ts new file mode 100644 index 000000000..9be608962 --- /dev/null +++ b/frontend/src/adk/wecomAuthorization.ts @@ -0,0 +1,77 @@ +import defaultSdk, { + WecomAIBotSDK, +} from "@wecom/wecom-aibot-sdk/dist/wecom-aibot-sdk.esm.js"; + +// The package creates a singleton listener on import; attempts own their instances. +defaultSdk.destroy(); + +export class WecomAuthorizationError extends Error { + constructor(public readonly code: string) { + super("WeCom authorization failed"); + } +} + +export function authorizeWecom( + signal: AbortSignal, +): Promise<{ botId: string; secret: string }> { + if (signal.aborted) + return Promise.reject(new DOMException("Cancelled", "AbortError")); + const sdk = new WecomAIBotSDK(); + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + settled = true; + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + sdk.destroy(); + }; + const fail = (error: unknown) => { + if (settled) return; + cleanup(); + reject(error); + }; + const abort = () => fail(new DOMException("Cancelled", "AbortError")); + const timeout = setTimeout( + () => fail(new WecomAuthorizationError("AUTH_TIMEOUT")), + 300_000, + ); + signal.addEventListener("abort", abort, { once: true }); + try { + // No await before opening: browsers require the original user activation. + sdk + .openBotInfoAuthWindow({ + source: "mpa-agent", + state: crypto.randomUUID(), + debug: false, + }) + .then( + (bot) => { + if (settled) return; + if ( + typeof bot?.botid !== "string" || + !bot.botid.trim() || + typeof bot?.secret !== "string" || + !bot.secret.trim() + ) { + fail(new WecomAuthorizationError("INVALID_RESULT")); + return; + } + cleanup(); + resolve({ botId: bot.botid.trim(), secret: bot.secret.trim() }); + }, + (error: unknown) => { + const code = + error && + typeof error === "object" && + "code" in error && + typeof error.code === "string" + ? error.code + : "FAILED"; + fail(new WecomAuthorizationError(code)); + }, + ); + } catch { + fail(new WecomAuthorizationError("FAILED")); + } + }); +} diff --git a/frontend/src/automations/mpa-channels/MpaChannelsAutomation.css b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.css new file mode 100644 index 000000000..3952c19da --- /dev/null +++ b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.css @@ -0,0 +1,153 @@ +.mpa-channels-page { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 32px 32px 0; + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} +.mpa-channels-header { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 24px; +} +.mpa-channels-header > div { + min-width: 0; +} +.mpa-channels-header h1 { + margin: 0; + font-size: 20px; + font-weight: 650; + line-height: 1.25; +} +.mpa-channels-header p { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} +.mpa-channels-icon { + width: 32px; + height: 32px; + flex: 0 0 32px; +} +.mpa-channels-back { + display: grid; + place-items: center; + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--panel)); + color: hsl(var(--muted-foreground)); + cursor: pointer; +} +.mpa-channels-back svg { + width: 16px; + height: 16px; +} +.mpa-channels-back:hover { + background: hsl(var(--secondary)); + color: hsl(var(--foreground)); +} +.mpa-channels-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 32px; + scrollbar-gutter: stable; +} +.mpa-channels-content { + display: grid; + gap: 24px; + max-width: 1040px; + min-width: 0; +} +.mpa-channels-picker { + display: grid; + gap: 12px; + min-width: 0; + padding: 20px 24px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--panel)); +} +.mpa-channels-picker-heading, +.mpa-channels-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.mpa-channels-picker h2 { + margin: 0; + font-size: 14px; + font-weight: 600; +} +.mpa-channels-picker p { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); + overflow-wrap: anywhere; +} +.mpa-channels-action { + flex-shrink: 0; + min-height: 32px; + padding: 4px 10px; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + cursor: pointer; + font: inherit; + font-size: 12px; +} +.mpa-channels-action:hover:not(:disabled) { + background: hsl(var(--secondary)); +} +.mpa-channels-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.mpa-channels-error { + display: flex; + align-items: center; + gap: 12px; +} +.mpa-channels-error p { + color: hsl(var(--destructive)); +} +.mpa-channels-notice { + margin: 0; + padding: 24px; + color: hsl(var(--muted-foreground)); + font-size: 13px; + line-height: 1.6; +} +.mpa-channels-back:focus-visible, +.mpa-channels-action:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} +@media (max-width: 640px) { + .mpa-channels-page { + padding: 20px 16px 0; + } + .mpa-channels-header { + align-items: flex-start; + } + .mpa-channels-picker { + padding: 16px; + } + .mpa-channels-pagination { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx new file mode 100644 index 000000000..5afbcd36f --- /dev/null +++ b/frontend/src/automations/mpa-channels/MpaChannelsAutomation.tsx @@ -0,0 +1,268 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + getRuntimes, + RuntimeListError, + type CloudRuntime, + type RuntimeScope, + type StudioRole, +} from "../../adk/client"; +import { formatRelativeTimeLabel } from "../../ui/relativeTime"; +import { DeploymentSelect } from "../../ui/DeploymentSelect"; +import { RuntimeChannels } from "../../ui/RuntimeChannels"; +import { TextShimmer } from "../../ui/text-shimmer/TextShimmer"; +import { MpaChannelsIcon } from "./MpaChannelsIcon"; +import "../../ui/ProjectPreview.css"; +import "./MpaChannelsAutomation.css"; + +interface MpaChannelsAutomationProps { + role: StudioRole; + runtimeScope: RuntimeScope; + onBack: () => void; +} + +const runtimeKey = (runtime: CloudRuntime) => + `${runtime.region}:${runtime.runtimeId}`; + +export function MpaChannelsAutomation({ + role, + runtimeScope, + onBack, +}: MpaChannelsAutomationProps) { + const { t } = useTranslation("automations"); + const authorized = role === "admin" || role === "super_admin"; + return ( +
+
+ + +
+

{t("cards.mpa-channels.name")}

+

{t("cards.mpa-channels.description")}

+
+
+
+ {authorized ? ( + + ) : ( +

+ {t("mpaChannels.unauthorized")} +

+ )} +
+
+ ); +} + +function MpaChannelSelector({ runtimeScope }: { runtimeScope: RuntimeScope }) { + const { t, i18n } = useTranslation("automations"); + const [runtimes, setRuntimes] = useState([]); + const [selectedKey, setSelectedKey] = useState(""); + const [query, setQuery] = useState(""); + const [nextToken, setNextToken] = useState(""); + const [loading, setLoading] = useState(true); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState<"unauthorized" | "loadFailed" | null>( + null, + ); + const request = useRef(null); + const failedCursor = useRef(""); + + async function loadPage(cursor = "") { + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + setLoading(true); + setError(null); + failedCursor.current = cursor; + try { + const page = await getRuntimes({ + agentCategory: "mpa", + scope: runtimeScope, + region: "all", + pageSize: 100, + nextToken: cursor || undefined, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + // Older filtered list responses may omit category metadata. + const items = page.runtimes.filter( + (runtime) => + runtime.runtimeId && + runtime.region && + (runtime.agentCategory === "mpa" || + runtime.agentCategory === undefined), + ); + setRuntimes((current) => [ + ...new Map( + (cursor ? [...current, ...items] : items).map((runtime) => [ + runtimeKey(runtime), + runtime, + ]), + ).values(), + ]); + if (!cursor) + setSelectedKey((current) => + items.some((runtime) => runtimeKey(runtime) === current) + ? current + : "", + ); + setNextToken(page.nextToken); + setLoaded(true); + } catch (cause) { + if (controller.signal.aborted) return; + const denied = + cause instanceof RuntimeListError && + (cause.status === 401 || cause.status === 403); + setError(denied ? "unauthorized" : "loadFailed"); + if (denied) { + setRuntimes([]); + setSelectedKey(""); + setNextToken(""); + setLoaded(false); + failedCursor.current = ""; + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + } + + useEffect(() => { + void loadPage(); + return () => request.current?.abort(); + // The parent remounts the selector whenever role or scope changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const selected = runtimes.find( + (runtime) => runtimeKey(runtime) === selectedKey, + ); + const keyword = query.trim().toLocaleLowerCase(); + const options = runtimes + .filter((runtime) => + `${runtime.name} ${runtime.runtimeId} ${runtime.region}` + .toLocaleLowerCase() + .includes(keyword), + ) + .map((runtime) => { + const createdAt = formatRelativeTimeLabel( + runtime.createdAt, + Date.now(), + i18n.resolvedLanguage || i18n.language, + ); + return { + value: runtimeKey(runtime), + label: runtime.name || runtime.runtimeId, + description: + runtime.description?.trim() || t("mpaChannels.noDescription"), + metadata: `${t("mpaChannels.optionMetadata", { + creator: runtime.author?.trim() || t("mpaChannels.unknownCreator"), + createdAt, + })}\n${runtime.region} · ${runtime.runtimeId}`, + }; + }); + + return ( +
+
+
+

{t("mpaChannels.selectAgent")}

+ +
+ + {selected && ( +

+ {selected.region} · {selected.runtimeId} +

+ )} + {loading && ( + + {t("mpaChannels.loading")} + + )} + {error && ( +
+

{t(`mpaChannels.${error}`)}

+ +
+ )} + {!loading && !error && loaded && !runtimes.length && !nextToken && ( +

{t("mpaChannels.empty")}

+ )} + {nextToken && ( +
+

{t("mpaChannels.moreAvailable")}

+ +
+ )} +
+ {selected ? ( + + ) : loaded && runtimes.length > 0 ? ( +

+ {t("mpaChannels.chooseHint")} +

+ ) : null} +
+ ); +} diff --git a/frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx b/frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx new file mode 100644 index 000000000..4e8dba4bc --- /dev/null +++ b/frontend/src/automations/mpa-channels/MpaChannelsIcon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; + +export function MpaChannelsIcon(props: SVGProps) { + return ( + + ); +} diff --git a/frontend/src/automations/mpaChannels.ts b/frontend/src/automations/mpaChannels.ts new file mode 100644 index 000000000..ea401afea --- /dev/null +++ b/frontend/src/automations/mpaChannels.ts @@ -0,0 +1,10 @@ +import type { MpaChannelsAutomationDefinition } from "./types"; + +export const mpaChannelsAutomation: MpaChannelsAutomationDefinition = { + id: "mpa-channels", + kind: "mpa-channels", + category: "channels", + icon: "mpa-channels", + name: "MPA agent messaging channels", + description: "Connect an existing MPA agent to Feishu, WeCom and DingTalk.", +}; diff --git a/frontend/src/automations/registry.ts b/frontend/src/automations/registry.ts index b9e0c4991..0bdd75b89 100644 --- a/frontend/src/automations/registry.ts +++ b/frontend/src/automations/registry.ts @@ -1,3 +1,4 @@ +import { mpaChannelsAutomation } from "./mpaChannels"; import { codingAgentsAutomation } from "./codingAgents"; import { feishuBotAutomation } from "./feishuBot"; import { gitLabMergeRequestReviewAutomation } from "./gitlabMergeRequestReview"; @@ -27,6 +28,7 @@ export const AUTOMATIONS: readonly AutomationDefinition[] = [ pullRequestReviewAutomation, gitLabMergeRequestReviewAutomation, feishuBotAutomation, + mpaChannelsAutomation, websiteIntegrationAutomation, ]; diff --git a/frontend/src/automations/types.ts b/frontend/src/automations/types.ts index d04080811..afdf58aba 100644 --- a/frontend/src/automations/types.ts +++ b/frontend/src/automations/types.ts @@ -11,11 +11,16 @@ export type AutomationId = | "gitlab-review" | "feishu" | "coding-agents" - | "website-integration"; + | "website-integration" + | "mpa-channels"; export type GitHubAutomationId = Exclude< AutomationId, - "gitlab-review" | "feishu" | "coding-agents" | "website-integration" + | "gitlab-review" + | "feishu" + | "coding-agents" + | "website-integration" + | "mpa-channels" >; export type AutomationCategoryId = "development" | "channels"; @@ -52,7 +57,13 @@ export interface AutomationFieldDefinition { export interface AutomationCardDefinition { id: AutomationId; category: AutomationCategoryId; - icon: "github" | "gitlab" | "feishu" | "coding-agents" | "website-integration"; + icon: + | "github" + | "gitlab" + | "feishu" + | "coding-agents" + | "website-integration" + | "mpa-channels"; name: string; badge?: string; badgeTone?: "default" | "success"; @@ -101,9 +112,15 @@ export interface WebsiteIntegrationAutomationDefinition extends AutomationCardDe kind: "website-integration"; } +export interface MpaChannelsAutomationDefinition extends AutomationCardDefinition { + id: "mpa-channels"; + kind: "mpa-channels"; +} + export type AutomationDefinition = | GitHubAutomationDefinition | GitLabAutomationDefinition | FeishuAutomationDefinition | CodingAgentAutomationDefinition - | WebsiteIntegrationAutomationDefinition; + | WebsiteIntegrationAutomationDefinition + | MpaChannelsAutomationDefinition; diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts index 76b823ddb..72aab2846 100644 --- a/frontend/src/blocks.ts +++ b/frontend/src/blocks.ts @@ -18,6 +18,7 @@ import type { MessageFeedbackState, } from "./adk/client"; import { i18n } from "./i18n/runtime"; +import { addTokenUsage, EMPTY_SESSION_TOKEN_USAGE } from "./adk/tokenUsage"; import type { A2uiMessage } from "./a2ui/types"; import type { SandboxTokenUsage } from "./adk/sandbox"; import type { ProjectFile } from "./create/project"; @@ -116,7 +117,14 @@ export type Block = { | { kind: "turn-summary"; value: DevelopmentTurnMetrics } | { kind: "diff"; text: string; done: boolean } | { kind: "progress"; text: string } - | { kind: "thinking"; text: string; done: boolean } + | { kind: "activity-source"; label: string } + | { + kind: "thinking"; + reasoningSegmentId?: string; + text: string; + done: boolean; + thoughtKind?: "reasoning" | "thought"; + } | { kind: "text"; text: string } | { kind: "tool"; @@ -127,6 +135,7 @@ export type Block = { done: boolean; status?: "running" | "completed" | "failed"; defaultOpen?: boolean; + source?: "codex-sandbox" | "studio" | "runtime"; codexActivity?: CodexSandboxActivity; progressText?: string; } @@ -171,10 +180,15 @@ export interface Acc { } export interface TurnMeta { + a2aStatus?: string; author?: string; localId?: string; streaming?: boolean; tokens?: number; + /** MPA A2A request usage deltas keyed by source/event identity. */ + mpaUsage?: Record; + /** Nonempty sandbox final, used only by the MPA A2A presentation view. */ + mpaFinalAnswer?: string; ts?: number; // epoch seconds eventId?: string; invocationId?: string; @@ -206,6 +220,7 @@ export function emptyAcc(): Acc { } const MAX_PENDING_CODEX_PROGRESS = 64; +const MAX_SEEN_EVENT_IDS = 2_048; function applyCodexProgressToTool( blocks: Block[], @@ -221,7 +236,10 @@ function applyCodexProgressToTool( if (block.kind !== "tool" || block.name !== progress.toolName) continue; if (block.callId === progress.requestId) { if (block.done) return "completed"; - block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress); + block.codexActivity = applyCodexSandboxProgress( + block.codexActivity, + progress, + ); block.status = progress.terminalStatus ?? "running"; if (progress.terminalStatus) block.done = true; return "applied"; @@ -237,7 +255,10 @@ function applyCodexProgressToTool( if (fallbackIndex >= 0) { const block = blocks[fallbackIndex]; if (block.kind !== "tool") return "unmatched"; - block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress); + block.codexActivity = applyCodexSandboxProgress( + block.codexActivity, + progress, + ); block.status = progress.terminalStatus ?? "running"; if (progress.terminalStatus) block.done = true; return "applied"; @@ -250,10 +271,13 @@ function codexResponseStatus(response: unknown): "completed" | "failed" { return "completed"; } const result = response as Record; - const status = typeof result.status === "string" ? result.status.toLowerCase() : ""; + const status = + typeof result.status === "string" ? result.status.toLowerCase() : ""; if ( - result.ok === false - || ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes(status) + result.ok === false || + ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes( + status, + ) ) { return "failed"; } @@ -261,7 +285,8 @@ function codexResponseStatus(response: unknown): "completed" | "failed" { } function codexDirectAnswer(response: unknown): string { - if (!response || typeof response !== "object" || Array.isArray(response)) return ""; + if (!response || typeof response !== "object" || Array.isArray(response)) + return ""; const result = response as Record; if (result.ok !== true || typeof result.message !== "string") return ""; return result.message.trim(); @@ -282,6 +307,136 @@ export interface AssistantEventProjection { const fnCall = (p: AdkPart) => p.functionCall ?? p.function_call; const fnResp = (p: AdkPart) => p.functionResponse ?? p.function_response; +function eventMetadata(ev: AdkEvent): Record { + const metadata = ev.customMetadata ?? ev.custom_metadata; + return metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? (metadata as Record) + : {}; +} + +function nestedMetadata( + metadata: Record, + key: string, +): Record { + const value = metadata[key]; + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function metadataString(metadata: Record, key: string): string { + const value = metadata[key]; + return typeof value === "string" ? value : ""; +} + +function mpaMetadata(ev: AdkEvent): Record { + return nestedMetadata(eventMetadata(ev), "mpa"); +} + +function mpaEventType(ev: AdkEvent): string { + const metadata = eventMetadata(ev); + const mpa = mpaMetadata(ev); + return ( + metadataString(metadata, "eventType") || + metadataString(metadata, "event_type") || + metadataString(mpa, "eventType") || + metadataString(mpa, "event_type") || + metadataString(mpa, "kind") + ); +} + +function isMpaSandboxEvent(ev: AdkEvent): boolean { + const metadata = eventMetadata(ev); + const mpa = mpaMetadata(ev); + return ( + metadataString(metadata, "source") === "sandbox" || + metadataString(mpa, "source") === "sandbox" + ); +} + +function isMpaUsageEvent(ev: AdkEvent): boolean { + const eventType = mpaEventType(ev); + return eventType === "usage.updated" || eventType === "usage"; +} + +function isFinalAlreadyEmittedSandboxResponse(ev: AdkEvent): boolean { + return (ev.content?.parts ?? []).some((part) => { + const response = fnResp(part); + const payload = response?.response; + return ( + response?.name === "sandbox_task" && + payload && + typeof payload === "object" && + !Array.isArray(payload) && + (payload as Record).finalAlreadyEmitted === true + ); + }); +} + +function completeSandboxTaskBlocks(blocks: Block[]) { + for (const block of blocks) { + if (block.kind !== "tool" || block.name !== "sandbox_task" || block.done) { + continue; + } + block.done = true; + block.status = "completed"; + } +} + +function toolNamesMatch(left: string, right: string): boolean { + if (left === right) return true; + const commandAliases = new Set([ + "exec_command", + "commandExecution", + "command_execution", + "Run command", + ]); + return commandAliases.has(left) && commandAliases.has(right); +} + +function toolResponseState( + response: unknown, + partial = false, +): { done: boolean; status: "running" | "completed" | "failed" } { + const responseStatus = + response && typeof response === "object" + ? String((response as Record).status ?? "").toLowerCase() + : ""; + const failed = + ["failed", "error", "cancelled", "denied", "timeout"].includes( + responseStatus, + ) || (response as Record | undefined)?.ok === false; + const running = ["running", "started", "working"].includes( + responseStatus, + ); + return { + done: failed || !(partial || running), + status: failed ? "failed" : partial || running ? "running" : "completed", + }; +} + +export function flattenCodexActivityBlocks(blocks: Block[]): Block[] { + return blocks.flatMap((block) => { + if ( + block.kind !== "tool" || + block.name !== "delegate_to_codex_sandbox" || + !block.codexActivity?.items.length + ) + return [block]; + return [ + { + kind: "activity-source" as const, + label: block.codexActivity.title || "Codex Sandbox", + }, + ...block.codexActivity.items.map(({ block: child }) => + child.kind === "tool" + ? { ...child, source: "codex-sandbox" as const } + : child, + ), + ]; + }); +} + function transferAgentName(args: unknown): string { if (!args || typeof args !== "object") return ""; const record = args as Record; @@ -301,18 +456,20 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] { const files: AttachmentView[] = []; for (const [index, p] of parts.entries()) { const metadata = (p.partMetadata ?? p.part_metadata) as - | Record - | undefined; - const transport = metadata?.veadkTransport as Record | undefined; + Record | undefined; + const transport = metadata?.veadkTransport as + Record | undefined; if (transport?.hidden === true) continue; const stored = metadata?.veadkMedia as Record | undefined; if (typeof stored?.uri === "string") { files.push({ id: String(stored.id ?? stored.uri), - mimeType: typeof stored.mimeType === "string" ? stored.mimeType : undefined, + mimeType: + typeof stored.mimeType === "string" ? stored.mimeType : undefined, uri: stored.uri, name: typeof stored.name === "string" ? stored.name : undefined, - sizeBytes: typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined, + sizeBytes: + typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined, }); continue; } @@ -342,9 +499,9 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] { function visiblePartText(part: AdkPart): string | undefined { const metadata = (part.partMetadata ?? part.part_metadata) as - | Record - | undefined; - const transport = metadata?.veadkTransport as Record | undefined; + Record | undefined; + const transport = metadata?.veadkTransport as + Record | undefined; return transport?.hideText === true ? undefined : part.text; } @@ -357,7 +514,9 @@ const AGENT_NODE_TYPES = new Set([ ]); /** Restore slash-skill and @agent selections persisted in part metadata. */ -export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | undefined { +export function invocationFromParts( + parts: AdkPart[], +): FrontendInvocation | undefined { for (const part of parts) { const raw = (part.partMetadata ?? part.part_metadata)?.veadkInvocation; if (!raw || typeof raw !== "object") continue; @@ -367,10 +526,15 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde if (!item || typeof item !== "object") return []; const skill = item as Record; return typeof skill.name === "string" - ? [{ + ? [ + { name: skill.name, - description: typeof skill.description === "string" ? skill.description : "", - }] + description: + typeof skill.description === "string" + ? skill.description + : "", + }, + ] : []; }) : []; @@ -388,9 +552,12 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde ) { targetAgent = { name: target.name, - description: typeof target.description === "string" ? target.description : "", + description: + typeof target.description === "string" ? target.description : "", type: type as AgentNodeType, - path: target.path.filter((item): item is string => typeof item === "string"), + path: target.path.filter( + (item): item is string => typeof item === "string", + ), }; } } @@ -406,24 +573,55 @@ function appendAttachments(blocks: Block[], files: AttachmentView[]) { else blocks.push({ kind: "attachment", files }); } -function appendArtifacts(blocks: Block[], files: { filename: string; version: number }[]) { +function appendArtifacts( + blocks: Block[], + files: { filename: string; version: number }[], +) { if (!files.length) return; const last = blocks[blocks.length - 1]; if (last?.kind === "artifact") { for (const file of files) { - if (!last.files.some((item) => - item.filename === file.filename && item.version === file.version - )) last.files.push(file); + if ( + !last.files.some( + (item) => + item.filename === file.filename && item.version === file.version, + ) + ) + last.files.push(file); } return; } blocks.push({ kind: "artifact", files }); } -function appendText(blocks: Block[], kind: "thinking" | "text", text: string) { +function appendText( + blocks: Block[], + kind: "thinking" | "text", + text: string, + thoughtKind?: "reasoning" | "thought", +) { const last = blocks[blocks.length - 1]; - if (last && last.kind === kind) last.text += text; - else blocks.push(kind === "thinking" ? { kind, text, done: false } : { kind, text }); + if ( + last && + last.kind === kind && + (last.kind !== "thinking" || last.thoughtKind === thoughtKind) + ) + last.text += text; + else + blocks.push( + kind === "thinking" + ? { kind, text, done: false, thoughtKind } + : { kind, text }, + ); +} + +function thoughtKindOf(event: AdkEvent): "reasoning" | "thought" { + const metadata = event.customMetadata ?? event.custom_metadata; + if (metadata && typeof metadata === "object") { + const value = (metadata as Record).thoughtKind; + if (value === "reasoning" || value === "thought") return value; + } + return "reasoning"; } function closeThinking(blocks: Block[]) { @@ -431,10 +629,26 @@ function closeThinking(blocks: Block[]) { } /** Apply one ADK event to a turn accumulator, returning a new accumulator. */ -export function applyEvent(acc: Acc, ev: AdkEvent): Acc { +export function applyEvent(acc: Acc, ev: AdkEvent, options: { mpaA2a?: boolean } = {}): Acc { const blocks = acc.blocks.map((b) => ({ ...b })); let liveStart = acc.liveStart; let pendingCodexProgress = acc.pendingCodexProgress.slice(); + if (isMpaUsageEvent(ev) && !options.mpaA2a) { + return { blocks, liveStart, pendingCodexProgress }; + } + if (isFinalAlreadyEmittedSandboxResponse(ev)) { + // The Runtime has already streamed the sandbox answer and uses this + // wrapper response only as the parent tool's completion signal. Preserve + // those live answer deltas while closing the parent activity card. Keep + // the preview replaceable by a later persisted sandbox final event. + completeSandboxTaskBlocks(blocks); + closeThinking(blocks); + return { + blocks, + liveStart, + pendingCodexProgress, + }; + } const parts = ev.content?.parts ?? []; const progressUpdates = parts.flatMap((part) => { const progress = parseBranchCompareProgress( @@ -453,14 +667,20 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { for (let index = blocks.length - 1; index >= 0; index -= 1) { const block = blocks[index]; if ( - block.kind !== "tool" - || block.done - || block.name !== progress.toolName - || (progress.requestId && block.callId && block.callId !== progress.requestId) + block.kind !== "tool" || + block.done || + block.name !== progress.toolName || + (progress.requestId && + block.callId && + block.callId !== progress.requestId) ) { continue; } - block.response = applyBranchCompareProgress(block.args, block.response, progress); + block.response = applyBranchCompareProgress( + block.args, + block.response, + progress, + ); block.status = "running"; break; } @@ -468,8 +688,9 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { for (const progress of codexProgressUpdates) { const outcome = applyCodexProgressToTool(blocks, progress); if (outcome === "unmatched") { - pendingCodexProgress = [...pendingCodexProgress, progress] - .slice(-MAX_PENDING_CODEX_PROGRESS); + pendingCodexProgress = [...pendingCodexProgress, progress].slice( + -MAX_PENDING_CODEX_PROGRESS, + ); } } return { blocks, liveStart, pendingCodexProgress }; @@ -480,22 +701,49 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { // Streaming delta: append into the live-preview region. for (const p of parts) { const text = visiblePartText(p); + const segmentId = options.mpaA2a && p.thought + ? metadataString(eventMetadata(ev), "reasoningSegmentId") : ""; + if (segmentId && typeof text === "string" && text) { + const previous = blocks.find((block) => + block.kind === "thinking" && block.reasoningSegmentId === segmentId); + if (previous?.kind === "thinking") previous.text += text; + else blocks.push({ kind: "thinking", text, done: false, + thoughtKind: thoughtKindOf(ev), reasoningSegmentId: segmentId }); + continue; + } if (typeof text === "string" && text) - appendText(blocks, p.thought ? "thinking" : "text", text); + appendText( + blocks, + p.thought ? "thinking" : "text", + text, + p.thought ? thoughtKindOf(ev) : undefined, + ); } return { blocks, liveStart, pendingCodexProgress }; } // Consolidated / final event: drop the live preview and append authoritative // content (merging consecutive same-kind text parts into one block). + // MPA finals can contain only the answer, without earlier outer/worker reasoning. + // Keep that reasoning unless this event explicitly supplies its replacement. + const preservedThinking = options.mpaA2a && + !parts.some((part) => part.thought && visiblePartText(part)) + ? blocks.slice(liveStart).filter((block) => block.kind === "thinking") + : []; blocks.length = liveStart; + blocks.push(...preservedThinking); for (const p of parts) { const fc = fnCall(p); const fr = fnResp(p); const files = attachmentsFromParts([p]); const text = visiblePartText(p); if (typeof text === "string" && text) { - appendText(blocks, p.thought ? "thinking" : "text", text); + appendText( + blocks, + p.thought ? "thinking" : "text", + text, + p.thought ? thoughtKindOf(ev) : undefined, + ); } else if (files.length) { closeThinking(blocks); appendAttachments(blocks, files); @@ -508,13 +756,17 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { ev.actions?.transfer_to_agent || i18n.t("app:common.unknownAgent"); blocks.push({ kind: "agent-transfer", agentName, done: false }); + } else if (fc.name === "sandbox_task" && isMpaSandboxEvent(ev)) { + continue; } else if (fc.name === REQUEST_EUC) { // MCP/tool OAuth: render a dedicated auth card instead of a tool row. const args = (fc.args ?? {}) as Record; const authConfig = args.authConfig ?? args.auth_config ?? args; // functionCallId looks like "_adk_toolset_auth_McpToolset"; surface the // toolset name so the card can say what is being authorized. - const rawId = String(args.functionCallId ?? args.function_call_id ?? ""); + const rawId = String( + args.functionCallId ?? args.function_call_id ?? "", + ); const label = rawId.replace(/^_adk_toolset_auth_/, "") || undefined; blocks.push({ kind: "auth", @@ -525,6 +777,23 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { done: false, }); } else { + const existingTool = fc.id + ? [...blocks] + .reverse() + .find( + (block: Block) => + block.kind === "tool" && block.callId === fc.id, + ) + : undefined; + if (existingTool?.kind === "tool") { + existingTool.name = fc.name ?? existingTool.name; + existingTool.args = fc.args ?? existingTool.args; + if (existingTool.response === undefined) { + existingTool.done = false; + existingTool.status = "running"; + } + continue; + } const toolBlock: Extract = { kind: "tool", name: fc.name ?? "", @@ -537,8 +806,8 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { const stillPending: CodexSandboxProgress[] = []; for (const progress of pendingCodexProgress) { if ( - progress.toolName === toolBlock.name - && progress.requestId === toolBlock.callId + progress.toolName === toolBlock.name && + progress.requestId === toolBlock.callId ) { applyCodexProgressToTool(blocks, progress); } else { @@ -569,34 +838,64 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { } } } + let matchedTool = false; for (let i = blocks.length - 1; i >= 0; i--) { const b = blocks[i]; - const isCodexTool = b.kind === "tool" && b.name === "delegate_to_codex_sandbox"; + const isCodexTool = + b.kind === "tool" && b.name === "delegate_to_codex_sandbox"; if ( - b.kind === "tool" - && (!b.done || isCodexTool) - && b.name === fr.name - && (!fr.id || !b.callId || b.callId === fr.id) + b.kind === "tool" && + (!b.done || isCodexTool || Boolean(fr.id && b.callId === fr.id)) && + ((Boolean(fr.id) && Boolean(b.callId) && b.callId === fr.id) || + ((!fr.id || !b.callId) && toolNamesMatch(b.name, fr.name ?? ""))) ) { const previousAnswer = isCodexTool ? codexDirectAnswer(b.response) : ""; - b.done = true; + const responseState = toolResponseState( + fr.response, + ev.partial === true, + ); + b.done = responseState.done; b.response = fr.response; + b.status = responseState.status; if (isCodexTool) { b.codexActivity = hydrateCodexSandboxActivity( b.codexActivity, fr.response, ); - b.status = codexResponseStatus(fr.response); + b.status = + responseState.status === "running" + ? "running" + : codexResponseStatus(fr.response); const answer = codexDirectAnswer(fr.response); if (answer && answer !== previousAnswer) { appendText(blocks, "text", answer); } } + matchedTool = true; break; } } + if ( + !matchedTool && + fr.name !== TRANSFER_AGENT_TOOL && + fr.name !== REQUEST_EUC && + fr.name !== A2UI_TOOL + ) { + const responseState = toolResponseState( + fr.response, + ev.partial === true, + ); + blocks.push({ + kind: "tool", + name: fr.name ?? "", + callId: fr.id, + response: fr.response, + done: responseState.done, + status: responseState.status, + }); + } if (fr.name === A2UI_TOOL) { const msgs = (fr.response?.[VALIDATED_JSON_KEY] as A2uiMessage[]) ?? []; if (msgs.length) { @@ -611,9 +910,15 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { if (artifactDelta) { appendArtifacts( blocks, - Object.entries(artifactDelta).map(([filename, version]) => ({ filename, version })), + Object.entries(artifactDelta).map(([filename, version]) => ({ + filename, + version, + })), ); } + if (isMpaSandboxEvent(ev) && mpaEventType(ev) === "invocation.completed") { + completeSandboxTaskBlocks(blocks); + } closeThinking(blocks); // a consolidated thinking segment is complete liveStart = blocks.length; return { blocks, liveStart, pendingCodexProgress }; @@ -621,6 +926,7 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { function completesAssistantResponse(ev: AdkEvent, blocks: Block[]): boolean { if (ev.partial === true) return false; + if (isFinalAlreadyEmittedSandboxResponse(ev)) return true; const parts = ev.content?.parts ?? []; const hasFinalAnswerPart = parts.some((part) => { const text = visiblePartText(part); @@ -631,26 +937,42 @@ function completesAssistantResponse(ev: AdkEvent, blocks: Block[]): boolean { }); const hasA2ui = parts.some((part) => { const response = fnResp(part); - return response?.name === A2UI_TOOL && + return ( + response?.name === A2UI_TOOL && Array.isArray(response.response?.[VALIDATED_JSON_KEY]) && - response.response[VALIDATED_JSON_KEY].length > 0; + response.response[VALIDATED_JSON_KEY].length > 0 + ); }); const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta; - const hasArtifact = Boolean(artifactDelta && Object.keys(artifactDelta).length > 0); + const hasArtifact = Boolean( + artifactDelta && Object.keys(artifactDelta).length > 0, + ); const agentEnded = Boolean( - ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate + ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate, ); - const hasAnswerBlock = blocks.some((block) => + const hasAnswerBlock = blocks.some( + (block) => block.kind === "text" || block.kind === "attachment" || block.kind === "artifact" || block.kind === "a2ui" || - block.kind === "delivery" + block.kind === "delivery", + ); + return ( + hasFinalAnswerPart || + hasA2ui || + hasArtifact || + (agentEnded && hasAnswerBlock) ); - return hasFinalAnswerPart || hasA2ui || hasArtifact || (agentEnded && hasAnswerBlock); +} + +function a2aStatusOf(ev: AdkEvent): string | undefined { + const status = (ev.customMetadata ?? ev.custom_metadata)?.a2aStatus; + return typeof status === "string" ? status : undefined; } function eventAffectsAssistantTurn(ev: AdkEvent): boolean { + if (a2aStatusOf(ev)) return true; const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta; if (artifactDelta && Object.keys(artifactDelta).length > 0) return true; return (ev.content?.parts ?? []).some((part) => @@ -658,11 +980,68 @@ function eventAffectsAssistantTurn(ev: AdkEvent): boolean { visiblePartText(part) || attachmentsFromParts([part]).length > 0 || fnCall(part) || - fnResp(part) - ) + fnResp(part), + ), ); } +/** Reconcile an authoritative outer snapshot, never a sandbox/token delta. */ +function reconcileMpaA2aReasoningSnapshot(acc: Acc, ev: AdkEvent): Acc { + const metadata = eventMetadata(ev); + const parts = ev.content?.parts ?? []; + if ( + ev.partial !== false || + metadata.projectionSource !== "a2a-artifact" || + isMpaSandboxEvent(ev) || + !parts.length || + ev.actions || + !parts.every((part) => part.thought && typeof part.text === "string" && + !fnCall(part) && !fnResp(part) && attachmentsFromParts([part]).length === 0) + ) return acc; + const previous = acc.blocks[acc.blocks.length - 1]; + const snapshot = parts.map((part) => part.text).join("").trim(); + if ( + previous?.kind !== "thinking" || + !previous.text.trim() || + !snapshot.startsWith(previous.text.trim()) + ) return acc; + // Re-open only this adjacent snapshot's preview range. Other activity is kept. + return { ...acc, liveStart: acc.blocks.length - 1 }; +} + +/** MPA A2A can split one user request into outer and sandbox assistant turns. */ +export function shouldShowEmptyAssistantResponse( + turns: Turn[], + index: number, + hasVisibleContent: (turn: Turn) => boolean, + options: { + mpaA2a: boolean; + turnIsStreaming: boolean; + requestIsStreaming: boolean; + }, +): boolean { + const turn = turns[index]; + if (!turn || turn.role !== "assistant" || options.turnIsStreaming || hasVisibleContent(turn)) { + return false; + } + if (!options.mpaA2a) return true; + let start = index; + while (start > 0 && turns[start - 1].role !== "user") start -= 1; + let end = index + 1; + while (end < turns.length && turns[end].role !== "user") end += 1; + if (end === turns.length && options.requestIsStreaming) return false; + const fragments = turns.slice(start, end); + if (fragments.some(hasVisibleContent)) return false; + // Empty status placeholders are not rendered, so anchor the notice to the + // last renderable fragment rather than a trailing heartbeat-only turn. + return !turns.slice(index + 1, end).some((fragment) => fragment.blocks.length > 0); +} + +function isUserEchoEvent(ev: AdkEvent): boolean { + if (ev.author !== "user" && ev.content?.role !== "user") return false; + return !(ev.content?.parts ?? []).some((part) => fnCall(part) || fnResp(part)); +} + /** Keep one mutable stream accumulator per active Agent response. Parallel * Agents can interleave token events, so a single "current author" loses * content and creates one turn per author switch. A completed response closes @@ -671,10 +1050,31 @@ function eventAffectsAssistantTurn(ev: AdkEvent): boolean { export function createAssistantEventProjector( localIdPrefix = "adk-stream", initialTurn?: Turn, + options: { mpaA2a?: boolean } = {}, ) { let sequence = 0; const active = new Map(); + const seenEventIds = new Set(); + const eventIdOrder: string[] = []; let seededKey: string | undefined; + let latestMpaTurn: Turn | undefined; + let mpaUsage = { ...initialTurn?.meta?.mpaUsage }; + + const recordMpaUsage = (ev: AdkEvent): boolean => { + if (!options.mpaA2a || !(ev.usageMetadata ?? ev.usage_metadata)) return false; + const count = addTokenUsage(EMPTY_SESSION_TOKEN_USAGE, ev).current.totalTokenCount; + if (!count) return false; + const metadata = eventMetadata(ev); + const key = JSON.stringify([ + metadata.source ?? "outer", + metadata.requestId ?? "", + ev.invocationId ?? ev.invocation_id ?? "", + ev.id || JSON.stringify(ev.usageMetadata ?? ev.usage_metadata), + ]); + if (Object.prototype.hasOwnProperty.call(mpaUsage, key)) return false; + mpaUsage = { ...mpaUsage, [key]: count }; + return true; + }; const keyFor = (author: string, invocationId: string) => `${invocationId}\u0000${author}`; @@ -682,7 +1082,8 @@ export function createAssistantEventProjector( if (initialTurn?.role === "assistant") { const author = initialTurn.meta?.author ?? ""; const invocationId = initialTurn.meta?.invocationId ?? ""; - const localId = initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`; + const localId = + initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`; const acc = emptyAcc(); acc.blocks = initialTurn.blocks; acc.liveStart = initialTurn.blocks.length; @@ -701,10 +1102,65 @@ export function createAssistantEventProjector( return { project(ev: AdkEvent): AssistantEventProjection { - const author = ev.author && ev.author !== "user" ? ev.author : ""; + if (isUserEchoEvent(ev)) { + return { + turn: { role: "assistant", blocks: [] }, + completed: false, + ignored: true, + }; + } + const usageChanged = recordMpaUsage(ev); + // Usage is metadata, including deltas arriving after the final answer. + // Update the existing fragment; never manufacture a new empty reply. + if ( + options.mpaA2a && !eventAffectsAssistantTurn(ev) && + !ev.content?.parts?.length + ) { + if (usageChanged && latestMpaTurn) { + latestMpaTurn = { + ...latestMpaTurn, + meta: { ...latestMpaTurn.meta, mpaUsage }, + }; + return { turn: latestMpaTurn, completed: false }; + } + return { turn: { role: "assistant", blocks: [] }, completed: false, ignored: true }; + } + if (isMpaUsageEvent(ev) && !options.mpaA2a) { + return { + turn: { role: "assistant", blocks: [] }, + completed: false, + ignored: true, + }; + } + if (ev.id && seenEventIds.has(ev.id)) { + return { + turn: { role: "assistant", blocks: [] }, + completed: false, + ignored: true, + }; + } + if (ev.id) { + seenEventIds.add(ev.id); + eventIdOrder.push(ev.id); + if (eventIdOrder.length > MAX_SEEN_EVENT_IDS) { + seenEventIds.delete(eventIdOrder.shift()!); + } + } + const rawAuthor = ev.author && ev.author !== "user" ? ev.author : ""; const invocationId = ev.invocationId ?? ev.invocation_id ?? ""; - const key = keyFor(author, invocationId); + let author = isMpaSandboxEvent(ev) ? "" : rawAuthor; + let key = keyFor(author, invocationId); let state = active.get(key); + if (!state && isMpaSandboxEvent(ev) && invocationId) { + const existing = [...active.entries()].find(([candidate]) => + candidate.startsWith(`${invocationId}\u0000`) + ); + if (existing) { + key = existing[0]; + state = existing[1]; + author = state.meta.author ?? ""; + } + } if (!state && seededKey) { const seeded = active.get(seededKey); if (seeded && (!seeded.meta.author || seeded.meta.author === author)) { @@ -713,6 +1169,18 @@ export function createAssistantEventProjector( state = seeded; } } + // The MPA live stream can omit the persisted sandbox + // `invocation.completed` event and finish with only the wrapper response. + // Use that response to close the active parent tool. During history + // replay the completed sandbox event has already closed the turn, so the + // same wrapper response must remain ignored to avoid a duplicate card. + if (!state && isFinalAlreadyEmittedSandboxResponse(ev)) { + return { + turn: { role: "assistant", blocks: [] }, + completed: false, + ignored: true, + }; + } if (!state && !eventAffectsAssistantTurn(ev)) { return { turn: { role: "assistant", blocks: [] }, @@ -725,11 +1193,18 @@ export function createAssistantEventProjector( state = { acc: emptyAcc(), localId, - meta: { author: author || undefined, invocationId: invocationId || undefined }, + meta: { + author: author || undefined, + invocationId: invocationId || undefined, + }, }; } - state.acc = applyEvent(state.acc, ev); + state.acc = applyEvent( + options.mpaA2a ? reconcileMpaA2aReasoningSnapshot(state.acc, ev) : state.acc, + ev, + options, + ); const usage = ev.usageMetadata ?? ev.usage_metadata; const completed = completesAssistantResponse(ev, state.acc.blocks); state.meta = { @@ -737,7 +1212,15 @@ export function createAssistantEventProjector( author: author || state.meta.author, localId: state.localId, streaming: !completed, + a2aStatus: a2aStatusOf(ev), tokens: usage?.totalTokenCount || state.meta.tokens, + ...(options.mpaA2a ? { + mpaUsage, + mpaFinalAnswer: isMpaSandboxEvent(ev) && mpaEventType(ev) === "invocation.completed" + ? (ev.content?.parts ?? []).filter((part) => !part.thought) + .map((part) => visiblePartText(part) ?? "").join("").trim() || undefined + : state.meta.mpaFinalAnswer, + } : {}), ts: ev.timestamp || state.meta.ts, invocationId: invocationId || state.meta.invocationId, eventId: completed && ev.id ? ev.id : state.meta.eventId, @@ -747,20 +1230,32 @@ export function createAssistantEventProjector( blocks: state.acc.blocks, meta: state.meta, }; - if (completed) { + const awaitsPersistedSandboxFinal = + completed && isFinalAlreadyEmittedSandboxResponse(ev); + if (completed && !awaitsPersistedSandboxFinal) { active.delete(key); seededKey = undefined; } else { + // Keep the terminal wrapper state addressable until transport finish. + // Some stored/replayed streams can still deliver the authoritative + // sandbox invocation.completed event immediately after the wrapper. active.set(key, state); } + if (options.mpaA2a) latestMpaTurn = turn; return { turn, completed }; }, finish(): Turn[] { const turns = [...active.values()].map((state): Turn => ({ role: "assistant", - blocks: state.acc.blocks, - meta: { ...state.meta, streaming: false }, + blocks: state.acc.blocks.map((block) => + block.kind === "thinking" ? { ...block, done: true } : block + ), + meta: { + ...state.meta, + streaming: false, + ...(options.mpaA2a ? { mpaUsage } : {}), + }, })); active.clear(); return turns; @@ -768,7 +1263,10 @@ export function createAssistantEventProjector( }; } -export function upsertProjectedAssistantTurn(turns: Turn[], projected: Turn): Turn[] { +export function upsertProjectedAssistantTurn( + turns: Turn[], + projected: Turn, +): Turn[] { const localId = projected.meta?.localId; if (!localId) return [...turns, projected]; const index = turns.findIndex((turn) => turn.meta?.localId === localId); @@ -782,9 +1280,13 @@ export function upsertProjectedAssistantTurn(turns: Turn[], projected: Turn): Tu export function eventsToTurns( events: AdkEvent[], sessionState: Record = {}, + options: { mpaA2a?: boolean } = {}, ): Turn[] { let turns: Turn[] = []; - let projector = createAssistantEventProjector("adk-history"); + let historySegment = 0; + const nextProjector = () => + createAssistantEventProjector(`adk-history-${historySegment++}`, undefined, options); + let projector = nextProjector(); for (const ev of events) { // Classify by author only: function-response events are authored by the // agent but carry content.role === "user", so a role-based check would @@ -799,7 +1301,10 @@ export function eventsToTurns( if (turns[i].role !== "assistant") continue; for (let j = turns[i].blocks.length - 1; j >= 0; j--) { const b = turns[i].blocks[j]; - if (b.kind === "auth") { b.done = true; break; } + if (b.kind === "auth") { + b.done = true; + break; + } } break; } @@ -822,6 +1327,7 @@ export function eventsToTurns( if (files.length) blocks.push({ kind: "attachment", files }); if (text) blocks.push({ kind: "text", text }); turns.push({ role: "user", blocks, meta: { ts: ev.timestamp } }); + projector = nextProjector(); } else { const projection = projector.project(ev); if (!projection.ignored) { diff --git a/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx b/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx index 273713a5e..32657b31b 100644 --- a/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx +++ b/frontend/src/components/ai-app/ConversationFlow/StudioConversation.tsx @@ -196,6 +196,11 @@ export function fromStudioTurns(turns: readonly Turn[], options: StudioConversat switch (block.kind) { case "turn-summary": return {id, type: "custom", content: }; case "diff": return { id, type: "custom", content: }; + case "activity-source": return { + id, + type: "custom", + content:
{block.label}
, + }; 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/components/layouts/ModalLayout/ModalLayout.tsx b/frontend/src/components/layouts/ModalLayout/ModalLayout.tsx index cabf51b45..9f34b117a 100644 --- a/frontend/src/components/layouts/ModalLayout/ModalLayout.tsx +++ b/frontend/src/components/layouts/ModalLayout/ModalLayout.tsx @@ -6,6 +6,8 @@ import "./ModalLayout.css"; export interface ModalLayoutProps extends Omit, "title"> { title: ReactNode; topGlow?: boolean; + /** Custom footer; null omits it while undefined preserves the default actions. */ + footer?: ReactNode; cancelLabel?: string; confirmLabel?: string; closeLabel?: string; @@ -14,7 +16,7 @@ export interface ModalLayoutProps extends Omit, " onConfirm?: () => void; } -export function ModalLayout({ title, topGlow = true, children, cancelLabel = "Cancel", confirmLabel = "Confirm", closeLabel = "Close", onClose, onCancel, onConfirm, className = "", ...props }: ModalLayoutProps) { +export function ModalLayout({ title, topGlow = true, children, footer, cancelLabel = "Cancel", confirmLabel = "Confirm", closeLabel = "Close", onClose, onCancel, onConfirm, className = "", ...props }: ModalLayoutProps) { const titleId = useId(); return
@@ -22,9 +24,11 @@ export function ModalLayout({ title, topGlow = true, children, cancelLabel = "Ca
{children}
-
+ {footer !== null &&
+ {footer === undefined ? <> -
+ : footer} +
}
; } diff --git a/frontend/src/components/primitives/Button/Button.css b/frontend/src/components/primitives/Button/Button.css index 5b7b65666..a3852722b 100644 --- a/frontend/src/components/primitives/Button/Button.css +++ b/frontend/src/components/primitives/Button/Button.css @@ -27,6 +27,11 @@ transition: background-color var(--studio-motion-duration-fast, 140ms) ease-out, color var(--studio-motion-duration-fast, 140ms) ease-out; } +.studio-button:disabled { + opacity: .45; + cursor: not-allowed; +} + /* Figma: 632:252803 */ .studio-button--secondary { background: #262626; diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx index 8daf7fd11..0ba5ea928 100644 --- a/frontend/src/create/CustomCreate.tsx +++ b/frontend/src/create/CustomCreate.tsx @@ -153,10 +153,12 @@ import { checkRuntimeNameAvailability, deleteGeneratedAgentTestRun, deployAgentkitProject, + applyMpaProfileAfterDeployment, generateAgentDraftFromRequirement, generateAgentProject, listModelApiKeys, listModelOptions, + retryMpaAgentOperation, type ModelApiKeyOption, runGeneratedAgentTestSSE, type ModelOption, @@ -3944,12 +3946,14 @@ interface CustomCreateProps extends CreateModeProps { /** Existing Runtime target when editing an Agent from the library. */ deploymentTarget?: { runtimeId: string; + mpaInstanceId?: string; name: string; region: string; appName?: string; currentVersion?: number | null; etag?: string; editMode?: "source-preserving" | "regenerate"; + mpaProfileOnly?: boolean; configuredMcpEnvKeys?: string[]; configuredRuntimeEnvKeys?: string[]; }; @@ -5115,6 +5119,7 @@ export function CustomCreate({ runtimeId: deploymentTarget?.runtimeId, runtimeName: options?.runtimeName ?? deploymentRuntimeName, appName: deploymentTarget?.appName, + agentCategory: "mpa", editMode: deploymentTarget?.editMode, draft: deploymentTarget || mcpGatewayManaged ? codegenDraft(draft) : undefined, @@ -5288,6 +5293,12 @@ export function CustomCreate({ pct: 0, }); let activeTask: DeploymentTaskUpdate | null = null; + let latestPhase = "prepare"; + let latestLabel = t("traditional.deployment.preparing"); + let latestMessage = t("traditional.deployment.generatingConfiguration"); + let latestRuntimeId = deploymentTarget?.runtimeId; + let latestRegion = deploymentTarget?.region ?? deployRegion; + let failedMpaRetry: (() => Promise) | undefined; try { if (!deploymentTarget) { const availability = await checkRuntimeNameAvailability( @@ -5304,17 +5315,17 @@ export function CustomCreate({ setProject(generated); const taskId = crypto.randomUUID(); const startedAt = Date.now(); - let latestPhase = "prepare"; - let latestLabel = t("traditional.deployment.preparing"); - let latestMessage = t("traditional.deployment.generatingConfiguration"); const taskBase = { id: taskId, ...(workspaceDraftId ? { draftId: workspaceDraftId } : {}), agentName: deploymentDraft.name, runtimeName: deploymentRuntimeName.trim(), - region: deployRegion, + runtimeId: deploymentTarget?.runtimeId, + region: deploymentTarget?.region ?? deployRegion, startedAt, agentDraft: deploymentDraft, + mpaProfile: true, + mpaSmoke: !deploymentTarget, }; const initialTask: DeploymentTaskUpdate = { ...taskBase, @@ -5328,6 +5339,82 @@ export function CustomCreate({ onDeploymentTaskChange?.(initialTask); onDeploymentStarted?.(initialTask); + if (deploymentTarget?.mpaProfileOnly) { + latestPhase = "profile_applying"; + latestLabel = t("traditional.deployment.stages.applyMpaProfile"); + latestMessage = t("traditional.deployment.applyingMpaProfile"); + setNewWorkbenchDeployStage({ + level: "info", + phase: latestPhase, + message: latestMessage, + pct: 0, + }); + onDeploymentTaskChange?.({ + ...taskBase, + status: "running", + phase: latestPhase, + label: latestLabel, + message: latestMessage, + pct: 0, + mpaProfile: true, + mpaSmoke: false, + }); + const operationKind = deploymentTarget.etag ? "update" : "create"; + const mpaOperation = await applyMpaProfileAfterDeployment({ + draft: deploymentDraft, + taskId, + agentName: deploymentDraft.name, + runtimeId: deploymentTarget.runtimeId, + region: deploymentTarget.region, + operationKind, + mpaInstanceId: deploymentTarget.mpaInstanceId, + runtimeName: deploymentRuntimeName.trim(), + sourceDraftId: workspaceDraftId, + runtimeRevision: deploymentTarget.etag, + }); + if (mpaOperation.status !== "succeeded") { + latestMessage = + mpaOperation.safeErrorCode || + t("traditional.deployment.mpaOperationIncomplete"); + setNewWorkbenchDeployStage({ + level: "error", + phase: latestPhase, + message: latestMessage, + pct: 100, + }); + throw new Error(latestMessage); + } + setNewWorkbenchDeploySucceeded(true); + setNewWorkbenchDeployStage({ + level: "success", + phase: latestPhase, + message: t("traditional.deployment.mpaProfileApplied"), + pct: 100, + }); + onDeploymentTaskChange?.({ + ...taskBase, + status: "success", + phase: "complete", + label: t("traditional.deployment.complete"), + message: t("traditional.deployment.mpaProfileApplied"), + pct: 100, + mpaProfile: true, + mpaSmoke: false, + }); + await onDeploymentComplete?.({ + apikey: "", + url: "", + agentName: deploymentDraft.name, + runtimeName: deploymentRuntimeName.trim(), + runtimeId: deploymentTarget.runtimeId, + mpaInstanceId: deploymentTarget.mpaInstanceId, + region: deploymentTarget.region, + version: deploymentTarget.currentVersion, + mpaOperation, + }); + return; + } + const envMap = new Map( Object.entries(allEnvValues) .map(([key, value]) => [key.trim(), value] as const) @@ -5384,6 +5471,149 @@ export function CustomCreate({ envs: [...envMap].map(([key, value]) => ({ key, value })), }, ); + let completeResult = result; + const mpaRuntimeId = result.runtimeId || deploymentTarget?.runtimeId || ""; + const mpaRegion = result.region || deploymentTarget?.region || deployRegion; + latestRuntimeId = mpaRuntimeId || latestRuntimeId; + latestRegion = mpaRegion; + if (mpaRuntimeId) { + latestPhase = "profile_applying"; + latestLabel = t("traditional.deployment.stages.applyMpaProfile"); + latestMessage = t("traditional.deployment.applyingMpaProfile"); + setNewWorkbenchDeployStage({ + level: "info", + phase: latestPhase, + message: latestMessage, + pct: 0, + }); + onDeploymentTaskChange?.({ + ...taskBase, + runtimeName: result.runtimeName || taskBase.runtimeName, + runtimeId: mpaRuntimeId, + region: mpaRegion, + status: "running", + phase: latestPhase, + label: latestLabel, + message: latestMessage, + pct: 0, + mpaProfile: true, + mpaSmoke: !deploymentTarget, + }); + const mpaOperation = await applyMpaProfileAfterDeployment({ + draft: deploymentDraft, + taskId, + agentName: deploymentDraft.name, + runtimeId: mpaRuntimeId, + region: mpaRegion, + operationKind: deploymentTarget ? "update" : "create", + mpaInstanceId: + deploymentTarget?.mpaInstanceId ?? result.mpaInstanceId, + runtimeName: deploymentRuntimeName.trim() || result.runtimeName, + sourceDraftId: workspaceDraftId, + runtimeRevision: deploymentTarget?.etag, + }); + if (mpaOperation.status !== "succeeded") { + latestPhase = + mpaOperation.stage === "smoke_running" + ? "smoke_running" + : "profile_applying"; + latestLabel = + latestPhase === "smoke_running" + ? t("traditional.deployment.stages.verifyMpaRuntime") + : t("traditional.deployment.stages.applyMpaProfile"); + latestMessage = + mpaOperation.safeErrorCode || + t("traditional.deployment.mpaOperationIncomplete"); + setNewWorkbenchDeployStage({ + level: "error", + phase: latestPhase, + message: latestMessage, + pct: 100, + }); + if (mpaOperation.status === "failed_retryable") { + const retryParams = { + operationKind: deploymentTarget ? "update" as const : "create" as const, + runtimeId: mpaRuntimeId, + region: mpaRegion, + mpaInstanceId: + deploymentTarget?.mpaInstanceId ?? result.mpaInstanceId ?? mpaRuntimeId, + sourceProfileId: workspaceDraftId?.trim() + ? `studio-draft:${workspaceDraftId.trim()}` + : `studio-agent:${deploymentDraft.name}:${taskId}`, + draft: deploymentDraft, + targetKey: deploymentTarget + ? deploymentTarget.mpaInstanceId ?? mpaRuntimeId + : `runtime:${mpaRegion}:${deploymentRuntimeName.trim() || result.runtimeName}`, + runtimeRevision: deploymentTarget?.etag, + }; + failedMpaRetry = async () => { + const retried = await retryMpaAgentOperation( + mpaOperation.operationId, + retryParams, + ); + onDeploymentTaskChange?.({ + ...taskBase, + runtimeId: mpaRuntimeId, + mpaInstanceId: retryParams.mpaInstanceId, + region: mpaRegion, + status: retried.status === "succeeded" ? "success" : "error", + phase: retried.stage, + label: retried.status === "succeeded" + ? t("traditional.deployment.complete") + : t("traditional.deployment.failed"), + message: retried.safeErrorCode || undefined, + pct: 100, + mpaProfile: true, + mpaSmoke: !deploymentTarget, + operationId: retried.operationId, + }); + if (retried.status !== "succeeded") { + throw new Error( + retried.safeErrorCode || t("traditional.deployment.mpaOperationIncomplete"), + ); + } + await onDeploymentComplete?.({ ...result, mpaOperation: retried }); + }; + } + throw new Error(latestMessage); + } + if (deploymentTarget) { + latestMessage = t("traditional.deployment.mpaProfileApplied"); + setNewWorkbenchDeployStage({ + level: "success", + phase: latestPhase, + message: latestMessage, + pct: 100, + }); + } else { + latestPhase = "smoke_running"; + latestLabel = t("traditional.deployment.stages.verifyMpaRuntime"); + latestMessage = t("traditional.deployment.runningMpaSmoke"); + onDeploymentTaskChange?.({ + ...taskBase, + runtimeName: result.runtimeName || taskBase.runtimeName, + runtimeId: mpaRuntimeId, + region: mpaRegion, + status: "running", + phase: latestPhase, + label: latestLabel, + message: latestMessage, + pct: 100, + mpaProfile: true, + mpaSmoke: true, + }); + latestPhase = "runnable"; + latestLabel = t("traditional.deployment.stages.mpaReady"); + latestMessage = t("traditional.deployment.mpaReady"); + setNewWorkbenchDeployStage({ + level: "success", + phase: latestPhase, + message: latestMessage, + pct: 100, + }); + } + completeResult = { ...result, mpaOperation }; + } setNewWorkbenchDeploySucceeded(true); setNewWorkbenchDeployStage({ level: "success", @@ -5393,16 +5623,20 @@ export function CustomCreate({ }); onDeploymentTaskChange?.({ ...taskBase, - runtimeName: result.runtimeName || taskBase.runtimeName, - runtimeId: result.runtimeId, - region: result.region || deployRegion, + runtimeName: completeResult.runtimeName || taskBase.runtimeName, + runtimeId: completeResult.runtimeId || deploymentTarget?.runtimeId, + region: completeResult.region || deploymentTarget?.region || deployRegion, status: "success", - phase: "complete", + phase: completeResult.mpaOperation && !deploymentTarget ? "runnable" : "complete", label: t("traditional.deployment.complete"), - message: result.warnings?.join(";"), + message: completeResult.mpaOperation && !deploymentTarget + ? t("traditional.deployment.mpaReady") + : completeResult.warnings?.join(";"), pct: 100, + mpaProfile: true, + mpaSmoke: Boolean(completeResult.mpaOperation && !deploymentTarget), }); - await onDeploymentComplete?.(result); + await onDeploymentComplete?.(completeResult); } catch (error) { const message = error instanceof Error ? error.message : String(error); setNewWorkbenchDeployError(message); @@ -5417,10 +5651,15 @@ export function CustomCreate({ startedAt: Date.now(), }), status: "error", - phase: activeTask?.phase, + runtimeId: latestRuntimeId, + region: latestRegion, + phase: latestPhase, label: t("traditional.deployment.failed"), message, - retry: () => deployFromNewWorkbench(deploymentOptions), + mpaProfile: true, + mpaSmoke: !deploymentTarget, + operationId: undefined, + retry: failedMpaRetry ?? (() => deployFromNewWorkbench(deploymentOptions)), }; onDeploymentTaskChange?.(failedTask); } finally { @@ -5488,6 +5727,7 @@ export function CustomCreate({ } onChange={(event) => setAiRequirement(event.target.value)} onKeyDown={(event) => { + if (isImeCompositionEvent(event.nativeEvent)) return; if (event.key === "Enter") { event.preventDefault(); void handleGenerateDraft(); @@ -5539,6 +5779,7 @@ export function CustomCreate({ deployRegion={deployRegion} runtimeName={deploymentRuntimeName} isRuntimeUpdate={Boolean(deploymentTarget)} + profileOnly={deploymentTarget?.mpaProfileOnly} deploying={newWorkbenchDeploying} deployStage={newWorkbenchDeployStage} deployError={newWorkbenchDeployError} @@ -6659,7 +6900,9 @@ export function CustomCreate({ } deploymentActionTargetId="cw-publish-primary-action" deploymentRuntimeId={deploymentTarget?.runtimeId} + deploymentMpaInstanceId={deploymentTarget?.mpaInstanceId} deploymentRuntimeName={deploymentRuntimeName} + deploymentRuntimeRevision={deploymentTarget?.etag} deploymentRuntimeNameCustomized={ !!deploymentTarget || !!draft.deployment?.runtimeNameCustomized diff --git a/frontend/src/create/NewAgentWorkbench.tsx b/frontend/src/create/NewAgentWorkbench.tsx index e146f07a7..a1ce9d74c 100644 --- a/frontend/src/create/NewAgentWorkbench.tsx +++ b/frontend/src/create/NewAgentWorkbench.tsx @@ -65,6 +65,7 @@ export interface NewAgentWorkbenchProps { deployRegion: string; runtimeName: string; isRuntimeUpdate?: boolean; + profileOnly?: boolean; deploying: boolean; deployStage: DeployStage | null; deployError: string; @@ -798,6 +799,7 @@ export function NewAgentWorkbench({ deployRegion, runtimeName, isRuntimeUpdate = false, + profileOnly = false, deploying, deployStage, deployError, @@ -889,7 +891,7 @@ export function NewAgentWorkbench({ ], ); const modelApiKeyMissing = - (modelSource === "ark" && !draft.deployment?.modelApiKeyId?.trim()) || + (!profileOnly && modelSource === "ark" && !draft.deployment?.modelApiKeyId?.trim()) || Boolean(missingCustomModelCredential); const showModelApiKeyError = modelApiKeyMissing && (showAgentErrors || !modelDataLoading); @@ -1001,6 +1003,18 @@ export function NewAgentWorkbench({ if (step === "agent") { setAgentValidationVisible(true); if (agentHasErrors) return; + if (profileOnly) { + onDeploy({ + authentication: { type: "api_key" }, + sessionStorage, + sessionBackend, + minInstance: Number(minInstance) || 1, + maxInstance: Number(maxInstance) || 1, + createEvaluationSets: false, + resources: deployResources, + }); + return; + } setStep("environment"); return; } @@ -1281,7 +1295,7 @@ export function NewAgentWorkbench({ ) : null} - {step === "environment" ? ( + {step === "environment" && !profileOnly ? ( ) : null} - {step === "deployment" ? ( + {step === "deployment" && !profileOnly ? ( - {step === "deployment" + {profileOnly + ? deploySucceeded + ? t("workbench.actions.applyProfileAgain") + : t("workbench.actions.applyProfile") + : step === "deployment" ? deploySucceeded ? isRuntimeUpdate ? t("workbench.actions.updateAgain") diff --git a/frontend/src/create/agentDraftStorage.ts b/frontend/src/create/agentDraftStorage.ts index 22972deb2..28754a845 100644 --- a/frontend/src/create/agentDraftStorage.ts +++ b/frontend/src/create/agentDraftStorage.ts @@ -26,12 +26,14 @@ export interface WorkspaceAgentDraft { creationMode?: WorkspaceAgentCreationMode; deploymentTarget?: { runtimeId: string; + mpaInstanceId?: string; name: string; region: string; appName?: string; currentVersion?: number | null; etag?: string; editMode?: "source-preserving" | "regenerate"; + mpaProfileOnly?: boolean; configuredMcpEnvKeys?: string[]; configuredRuntimeEnvKeys?: string[]; }; diff --git a/frontend/src/create/skills/skillspace.ts b/frontend/src/create/skills/skillspace.ts index 936b6a3ff..531b5e488 100644 --- a/frontend/src/create/skills/skillspace.ts +++ b/frontend/src/create/skills/skillspace.ts @@ -69,12 +69,13 @@ export interface SkillSpacePageOptions { page: number; pageSize: number; project?: string; + signal?: AbortSignal; } -async function jfetch(url: string): Promise { +async function jfetch(url: string, signal?: AbortSignal): Promise { const res = await fetch(withAuth(url), { headers: withLocaleHeaders(withLocalUser({ accept: "application/json" })), - signal: requestSignal(undefined, DEFAULT_REQUEST_TIMEOUT_MS), + signal: requestSignal(signal, DEFAULT_REQUEST_TIMEOUT_MS), }); if (!res.ok) { throw await skillApiErrorFromResponse( @@ -123,6 +124,7 @@ export async function listSkillsInSpacePage( if (options.project) params.set("project", options.project); return jfetch>( `/web/skill-spaces/${encodeURIComponent(spaceId)}/skills?${params.toString()}`, + options.signal, ); } diff --git a/frontend/src/cronjobs/CronJobs.tsx b/frontend/src/cronjobs/CronJobs.tsx index 9f3283f94..d8bb9e442 100644 --- a/frontend/src/cronjobs/CronJobs.tsx +++ b/frontend/src/cronjobs/CronJobs.tsx @@ -1,3 +1,5 @@ +import { MpaCronTasks } from "./MpaCronTasks"; +import type { MpaRuntime } from "../adk/mpaCronTasks"; import { useCallback, useEffect, @@ -77,6 +79,7 @@ import { CronJobFinalAnswer } from "./CronJobFinalAnswer"; import "./CronJobs.css"; interface CronJobsProps { + selectedRuntime?: MpaRuntime; cloudProvider: CloudProvider; } @@ -560,7 +563,8 @@ function JobDetail({ ); } -export function CronJobs({ cloudProvider }: CronJobsProps) { +export function CronJobs({ cloudProvider, selectedRuntime }: CronJobsProps) { + const [source, setSource] = useState("studio"); useTranslation("cronjobs"); const [jobs, setJobs] = useState([]); const [runtimes, setRuntimes] = useState([]); @@ -744,6 +748,11 @@ export function CronJobs({ cloudProvider }: CronJobsProps) { className="cronjobs-page-head" title={cronText("page.title")} /> + + + + {source === "runtime" ? : <> {loading && jobs.length === 0 ? : error ? {cronText("page.loadFailed")}{error} : 0} onCreate={() => setDrawerJob(null)} onSelect={(job) => setSelectedId(job.jobId)} />} + } {drawerJob !== undefined ? setDrawerJob(undefined)} onSubmit={submitDrawer} /> : null} ); diff --git a/frontend/src/cronjobs/MpaCronTasks.css b/frontend/src/cronjobs/MpaCronTasks.css new file mode 100644 index 000000000..77d73828c --- /dev/null +++ b/frontend/src/cronjobs/MpaCronTasks.css @@ -0,0 +1,427 @@ +.mpa-cron { + min-width: 0; + padding-bottom: 24px; + font-size: 13px; + color: hsl(var(--foreground)); +} +.mpa-cron-target { + display: flex; + gap: 8px 16px; + flex-wrap: wrap; + overflow-wrap: anywhere; + color: hsl(var(--muted-foreground)); + font-size: 12px; + margin-bottom: 20px; +} +.mpa-cron-target strong { + color: hsl(var(--foreground)); + font-weight: 500; +} +.mpa-cron-heading, +.mpa-toolbar, +.mpa-calendar-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.mpa-cron-heading { + margin-bottom: 24px; +} +.mpa-segment { + display: flex; + padding: 3px; + background: hsl(var(--muted)); + border-radius: 999px; +} +.mpa-segment button { + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + padding: 4px 16px; + border-radius: 999px; + min-height: 28px; + cursor: pointer; +} +.mpa-segment button[aria-pressed="true"] { + background: hsl(var(--panel)); + color: hsl(var(--foreground)); +} +.mpa-create { + border-radius: 999px !important; + gap: 8px; +} +.mpa-cron-search { + display: flex; + gap: 8px; + align-items: center; +} +.mpa-cron-search input { + width: 180px; + min-width: 0; + height: 34px; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + padding: 0 12px; + background: hsl(var(--panel)); + color: inherit; +} +.mpa-cron-overview { + display: flex; + gap: 16px; + flex-wrap: wrap; + color: hsl(var(--muted-foreground)); + font-size: 12px; + font-variant-numeric: tabular-nums; + margin-left: auto; +} +.mpa-cron-overview strong { + color: hsl(var(--foreground)); + font-weight: 500; +} +.mpa-cron-table { + overflow-x: auto; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + margin-top: 12px; +} +.mpa-cron table, +.mpa-detail table { + width: 100%; + border-collapse: collapse; + text-align: left; + table-layout: fixed; + min-width: 850px; +} +.mpa-cron th, +.mpa-cron td, +.mpa-detail th, +.mpa-detail td { + padding: 18px 20px; + border-bottom: 1px solid hsl(var(--border)); + vertical-align: middle; + font-weight: 400; + overflow-wrap: anywhere; +} +.mpa-cron th { + font-weight: 500; + padding-top: 12px; + padding-bottom: 12px; +} +.mpa-cron tbody tr:last-child td { + border-bottom: 0; +} +.mpa-cron tbody tr:hover { + background: hsl(var(--muted) / 0.45); +} +.mpa-cron th:last-child { + width: 140px; +} +.mpa-cron th:nth-child(3) { + width: 24%; +} +.mpa-cron th:nth-child(4) { + width: 180px; +} +.mpa-cron th .deployment-select { + width: 100%; +} +.mpa-task-link { + border: 0; + padding: 0; + background: none; + color: hsl(var(--feature-link)); + cursor: pointer; + text-align: left; + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.mpa-status { + display: inline-block; + border: 1px solid hsl(var(--border)); + border-radius: 4px; + padding: 2px 8px; + white-space: nowrap; + color: hsl(var(--muted-foreground)); + font-size: 12px; +} +.mpa-status-Succeeded { + color: hsl(150 52% 35%); + border-color: hsl(150 35% 75%); +} +.mpa-status-Failed, +.mpa-status-TimedOut { + color: hsl(var(--destructive)); + border-color: hsl(var(--destructive) / 0.3); +} +.mpa-status-Running { + color: hsl(var(--primary)); +} +.mpa-actions { + display: flex; + align-items: center; + gap: 8px; +} +.mpa-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: none; + color: hsl(var(--muted-foreground)); + width: 32px; + height: 32px; + border-radius: 6px; + cursor: pointer; +} +.mpa-icon-button:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); +} +.mpa-switch { + display: inline-flex; + align-items: center; + border: 0; + padding: 3px; + border-radius: 999px; + background: hsl(var(--muted)); + width: 32px; + height: 20px; + cursor: pointer; + flex-shrink: 0; +} +.mpa-switch span { + width: 14px; + height: 14px; + border-radius: 50%; + background: hsl(var(--panel)); + border: 1px solid hsl(var(--border)); +} +.mpa-switch[aria-checked="true"] { + background: hsl(var(--primary)); + justify-content: flex-end; +} +.mpa-cron button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.mpa-cron :focus-visible { + outline: 2px solid hsl(var(--primary)); + outline-offset: 2px; +} +.mpa-cron-pages { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + font-variant-numeric: tabular-nums; +} +.mpa-cron-pages span { + margin-right: auto; + color: hsl(var(--muted-foreground)); +} +.mpa-state { + padding: 40px 24px; + text-align: center; + color: hsl(var(--muted-foreground)); +} +.mpa-cron-error { + color: hsl(var(--destructive)); + line-height: 1.6; +} +.mpa-dialog { + width: min(680px, calc(100vw - 32px)); +} +.mpa-editor fieldset { + border: 0; + padding: 0; + margin: 0; + display: grid; + gap: 16px; + min-width: 0; +} +.mpa-editor label { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; +} +.mpa-editor textarea { + height: auto; + resize: vertical; + min-height: 120px; +} +.mpa-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.mpa-editor .mpa-checkbox { + flex-direction: row; + align-items: center; +} +.mpa-details { + display: grid; + grid-template-columns: 100px 1fr; + gap: 16px; + margin: 0; +} +.mpa-details dt { + color: hsl(var(--muted-foreground)); +} +.mpa-details dd { + margin: 0; + overflow-wrap: anywhere; +} +.mpa-prompt { + white-space: pre-wrap; + max-height: 220px; + overflow: auto; +} +.mpa-history-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 24px; +} +.mpa-history-title h3 { + font-size: 15px; + font-weight: 500; +} +.mpa-detail { + width: min(900px, calc(100vw - 32px)); +} +.mpa-detail table { + min-width: 650px; +} +.mpa-calendar-bar { + margin: 20px 0 12px; + gap: 8px; +} +.mpa-calendar-bar strong { + margin-right: auto; + font-size: 17px; + font-weight: 500; +} +.mpa-calendar-bar span { + font-size: 12px; + color: hsl(var(--muted-foreground)); +} +.mpa-calendar { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + overflow: hidden; +} +.mpa-calendar-weekday { + text-align: center; + padding: 12px; + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted) / 0.35); +} +.mpa-calendar-day { + min-height: 110px; + padding: 8px; + border-top: 1px solid hsl(var(--border)); + border-right: 1px solid hsl(var(--border)); + overflow: hidden; +} +.mpa-calendar-day:nth-child(7n) { + border-right: 0; +} +.mpa-calendar-day time { + font-variant-numeric: tabular-nums; + display: block; + margin-bottom: 8px; +} +.mpa-calendar-day.is-outside { + background: hsl(var(--muted) / 0.3); + color: hsl(var(--muted-foreground)); +} +.mpa-calendar-task { + display: block; + width: 100%; + border: 0; + border-radius: 4px; + text-align: left; + padding: 4px 6px; + margin-top: 4px; + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.07); + cursor: pointer; + font-size: 12px; +} +.mpa-calendar-task span { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mpa-cron > p { + line-height: 1.6; +} +@media (max-width: 760px) { + .mpa-toolbar { + align-items: stretch; + } + .mpa-cron-overview { + margin-left: 0; + } + .mpa-cron-search { + width: 100%; + } + .mpa-cron-search input { + flex: 1; + } + .mpa-fields { + grid-template-columns: 1fr; + } + .mpa-calendar-day { + min-height: 80px; + padding: 4px; + } + .mpa-calendar-task { + padding: 4px 2px; + } + .mpa-details { + grid-template-columns: 1fr; + gap: 8px; + } +} +.mpa-status-filter { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: 0; + padding: 0; + color: inherit; + cursor: pointer; + white-space: nowrap; + min-height: 28px; +} +.mpa-status-filter .icon { + width: 14px; + height: 14px; + color: hsl(var(--muted-foreground)); +} +.mpa-editor input[type="checkbox"] { + appearance: auto; + width: 16px; + height: 16px; + accent-color: hsl(var(--primary)); +} +.mpa-dialog form { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.mpa-dialog .mpa-editor { + flex: 1; +} diff --git a/frontend/src/cronjobs/MpaCronTasks.tsx b/frontend/src/cronjobs/MpaCronTasks.tsx new file mode 100644 index 000000000..2eefbc13e --- /dev/null +++ b/frontend/src/cronjobs/MpaCronTasks.tsx @@ -0,0 +1,621 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Menu } from "@openai/apps-sdk-ui/components/Menu"; +import { listMpaCronTasks, requestMpaTask } from "../adk/client"; +import type { + MpaCronTask, + MpaCronTaskPage, + MpaRuntime, + TaskFields, +} from "../adk/mpaCronTasks"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import { DialogShell } from "../ui/SandboxControls"; +import { MpaTaskEditor } from "./MpaTaskEditor"; +import { MpaTaskDetail, mpaErrorKey } from "./MpaTaskDetail"; +import { MpaIcon } from "./MpaTaskIcons"; +import { + calendarTimes, + formatTime, + monthDays, + runStatuses, + scheduleText, + taskStatus, +} from "./mpaSchedule"; +import "../create/CustomCreate.css"; +import "../ui/ProjectPreview.css"; +import "./MpaCronTasks.css"; + +export function MpaCronTasks({ runtime }: { runtime?: MpaRuntime }) { + const { t } = useTranslation("cronjobs"); + return runtime ? ( + + ) : ( +

{t("mpa.selectRuntime")}

+ ); +} +function RuntimeTasks({ runtime }: { runtime: MpaRuntime }) { + const { t, i18n } = useTranslation("cronjobs"); + const label = (key: string) => t(`mpa.manage.${key}`); + const [draftQuery, setDraftQuery] = useState(""), + [query, setQuery] = useState(""), + [index, setIndex] = useState(0), + [refresh, setRefresh] = useState(0); + const [page, setPage] = useState(null), + [loading, setLoading] = useState(true), + [error, setError] = useState(""), + [notice, setNotice] = useState(""); + const [view, setView] = useState("list"), + [status, setStatus] = useState("all"), + [month, setMonth] = useState(() => new Date()); + const [editor, setEditor] = useState<{ + task?: MpaCronTask; + copy: boolean; + } | null>(null), + [detail, setDetail] = useState(null), + [deleting, setDeleting] = useState(null); + const [busy, setBusy] = useState(false), + [writeError, setWriteError] = useState(""); + const locked = useRef(false), + composing = useRef(false); + const lifetime = useRef(new AbortController()); + const tokens = useRef(new Map()); + useEffect(() => { + const controller = new AbortController(); + lifetime.current = controller; + return () => controller.abort(); + }, []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + setPage(null); + void (async () => { + const first = await listMpaCronTasks( + runtime, + 0, + query, + controller.signal, + ); + const items = [...first.items]; + let current = first; + let offset = 0; + while (current.hasMore) { + if (controller.signal.aborted) return; + if ( + current.nextOffset === null || + current.nextOffset <= offset || + items.length > 100000 + ) + throw new Error("MPA_INVALID_RESPONSE"); + offset = current.nextOffset; + current = await listMpaCronTasks( + runtime, + offset, + query, + controller.signal, + ); + items.push(...current.items); + } + if (!controller.signal.aborted) + setPage({ + ...first, + items: [...new Map(items.map((task) => [task.id, task])).values()], + hasMore: false, + nextOffset: null, + }); + })() + .catch((e) => { + if (!controller.signal.aborted) setError(mpaErrorKey(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [runtime.runtimeId, runtime.region, query, refresh]); + const filtered = useMemo( + () => + page?.items.filter( + (task) => status === "all" || taskStatus(task) === status, + ) || [], + [page, status], + ); + const days = useMemo(() => monthDays(month), [month]); + const calendar = useMemo( + () => + days.map((day) => ({ + day, + tasks: filtered.flatMap((task) => { + const entry = calendarTimes(task, day); + return entry ? [{ task, ...entry }] : []; + }), + })), + [days, filtered], + ); + const visible = filtered.slice(index, index + 10); + function token(key: string) { + let value = tokens.current.get(key); + if (!value) { + value = crypto.randomUUID(); + tokens.current.set(key, value); + } + return value; + } + async function mutate( + kind: "create" | "edit" | "delete" | "run" | "toggle", + task?: MpaCronTask, + fields?: TaskFields, + ) { + if (locked.current) return; + locked.current = true; + setBusy(true); + setWriteError(""); + setNotice(""); + const operation = lifetime.current; + const key = + kind === "create" ? JSON.stringify(fields) : `${kind}:${task?.id}`; + try { + const suffix = task + ? `/${encodeURIComponent(task.id)}${kind === "run" ? "/run" : ""}` + : ""; + if ( + (kind === "edit" || kind === "toggle") && + (!Number.isInteger(task?.version) || Number(task?.version) < 1) + ) + throw new Error("MPA_INVALID_RESPONSE"); + const payload = + kind === "delete" + ? undefined + : kind === "run" + ? { clientToken: token(key), mode: "Force" } + : kind === "create" + ? { ...fields, clientToken: token(key) } + : { + ...(kind === "toggle" ? { enabled: !task!.enabled } : fields), + expectedVersion: task!.version, + }; + const result = await requestMpaTask( + runtime, + kind === "delete" ? "DELETE" : "POST", + suffix, + payload, + operation.signal, + ); + if ( + kind === "delete" + ? result.removed !== true + : kind === "run" + ? !result.run + : !result.task + ) + throw new Error("MPA_INVALID_RESPONSE"); + tokens.current.delete(key); + if (!operation.signal.aborted) { + setEditor(null); + setDeleting(null); + setDetail(null); + setNotice(label(kind === "run" ? "queued" : "saved")); + setIndex(0); + setRefresh((n) => n + 1); + } + } catch (e) { + if (!operation.signal.aborted) setWriteError(t(mpaErrorKey(e))); + } finally { + locked.current = false; + if (!operation.signal.aborted) setBusy(false); + } + } + function openEditor(task?: MpaCronTask, copy = false) { + setWriteError(""); + setEditor({ task, copy }); + } + return ( +
+
+ {runtime.name} + + {runtime.runtimeId} · {runtime.region} + +
+
+
+ {["list", "calendar"].map((mode) => ( + + ))} +
+ +
+
+
{ + e.preventDefault(); + if (composing.current) return; + setIndex(0); + setQuery(draftQuery); + setRefresh((n) => n + 1); + }} + > + setDraftQuery(e.target.value)} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + (composing.current || + e.nativeEvent.isComposing || + e.keyCode === 229) + ) + e.preventDefault(); + }} + /> + + +
+ {page?.overview && ( +
+ {label("overview")} + + {t("mpa.successRate")}{" "} + {Math.round(page.overview.successRate * 100)}% + + + {t("mpa.executionCount")}{" "} + {page.overview.executionCount} + +
+ )} +
+ {error && ( +

+ {t(error)} +

+ )} + {writeError && !editor && !deleting && ( +

+ {writeError} +

+ )} + {notice &&

{notice}

} + {loading && ( +
+ {t("mpa.loading")} +
+ )} + {!loading && !error && page && ( + <> + {view === "list" ? ( + <> +
+ + + + + + + + + + + + + {visible.map((task) => ( + + + + + + + + + ))} + +
{label("name")}{label("lastRun")}{label("schedule")} + + + + + + {["all", "Pending", ...runStatuses].map((value) => ( + { + setStatus(value); + setIndex(0); + }} + > + {label(value)} + + ))} + + + {label("agent")}{label("actions")}
+ + {formatTime(task.lastRunAt, i18n.language)}{scheduleText(task, label, i18n.language)} + + {label(taskStatus(task))} + + {task.agentId || "—"} +
+ + + + + + + + {( + ["detail", "edit", "copy", "delete"] as const + ).map((action) => ( + { + if (action === "detail") setDetail(task); + else if (action === "delete") { + setWriteError(""); + setDeleting(task); + } else + openEditor(task, action === "copy"); + }} + > + + {label(action)} + + ))} + + +
+
+ {filtered.length === 0 && ( +
+ {t("mpa.empty")} +
+ )} +
+ + + ) : ( + <> +
+ + {new Intl.DateTimeFormat(i18n.language, { + year: "numeric", + month: "long", + }).format(month)} + + + {label("calendarZone")}{" "} + {Intl.DateTimeFormat().resolvedOptions().timeZone} + + + + +
+
+ {days.slice(0, 7).map((d) => ( +
+ {new Intl.DateTimeFormat(i18n.language, { + weekday: "short", + }).format(d)} +
+ ))} + {calendar.map(({ day, tasks }) => ( +
+ + {tasks.map(({ task, at, count, nextOnly }) => ( + + ))} +
+ ))} +
+

{label("calendarHint")}

+ + )} + + )} + {editor && ( + setEditor(null)} + onSave={(fields) => + void mutate( + editor.task && !editor.copy ? "edit" : "create", + editor.copy ? undefined : editor.task, + fields, + ) + } + /> + )} + {detail && ( + setDetail(null)} + /> + )} + {deleting && ( + } + busy={busy} + onClose={() => setDeleting(null)} + > +
+

{t("mpa.manage.deleteConfirm", { name: deleting.name })}

+ {writeError && ( +

+ {writeError} +

+ )} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/cronjobs/MpaTaskDetail.tsx b/frontend/src/cronjobs/MpaTaskDetail.tsx new file mode 100644 index 000000000..af177bb03 --- /dev/null +++ b/frontend/src/cronjobs/MpaTaskDetail.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { listMpaRuns } from "../adk/client"; +import type { MpaCronTask, MpaRunPage, MpaRuntime } from "../adk/mpaCronTasks"; +import { DialogShell } from "../ui/SandboxControls"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import { formatTime, scheduleText } from "./mpaSchedule"; +import { MpaIcon } from "./MpaTaskIcons"; +export function mpaErrorKey(error: unknown): string { + const message = error instanceof Error ? error.message : ""; + return ( + ( + { + MPA_HTTP_401: "mpa.authRequired", + MPA_HTTP_403: "mpa.forbidden", + MPA_HTTP_404: "mpa.unsupported", + MPA_HTTP_409: "mpa.manage.conflict", + MPA_HTTP_422: "mpa.manage.invalid", + MPA_INVALID_RESPONSE: "mpa.invalidResponse", + } as Record + )[message] || "mpa.loadFailed" + ); +} +export function MpaTaskDetail({ + task, + runtime, + onClose, +}: { + task: MpaCronTask; + runtime: MpaRuntime; + onClose: () => void; +}) { + const { t, i18n } = useTranslation("cronjobs"); + const label = (key: string) => t(`mpa.manage.${key}`); + const [offset, setOffset] = useState(0), + [refresh, setRefresh] = useState(0), + [page, setPage] = useState(null), + [error, setError] = useState(""), + [loading, setLoading] = useState(true); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + setPage(null); + void listMpaRuns(runtime, task.id, offset, controller.signal) + .then((data) => { + if (!controller.signal.aborted) setPage(data); + }) + .catch((e) => { + if (!controller.signal.aborted) setError(mpaErrorKey(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [runtime.runtimeId, runtime.region, task.id, offset, refresh]); + return ( + } + className="mpa-dialog mpa-detail" + onClose={onClose} + > +
+
+
{label("agent")}
+
{task.agentId || "—"}
+
{label("schedule")}
+
{scheduleText(task, label, i18n.language)}
+
{t("mpa.columns.next")}
+
{formatTime(task.nextRunAt, i18n.language)}
+
{label("prompt")}
+
{task.prompt}
+
+
+

{label("history")}

+ +
+ {error && ( +

+ {t(error)} +

+ )} + {loading && {t("mpa.loading")}} + {page && ( + <> +
+ + + + {["lastRun", "lastStatus", "duration", "result"].map( + (k) => ( + + ), + )} + + + + {page.items.map((run) => ( + + + + + + + ))} + +
{label(k)}
+ {formatTime( + run.startedAt || run.scheduledAt, + i18n.language, + )} + {label(run.status)} + {run.durationMs == null ? "—" : `${run.durationMs} ms`} + {run.errorMessage || run.sessionId || "—"}
+
+ {page.items.length === 0 &&

{label("noRuns")}

} + + + )} +
+
+ ); +} diff --git a/frontend/src/cronjobs/MpaTaskEditor.tsx b/frontend/src/cronjobs/MpaTaskEditor.tsx new file mode 100644 index 000000000..321b5be84 --- /dev/null +++ b/frontend/src/cronjobs/MpaTaskEditor.tsx @@ -0,0 +1,346 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { DialogShell } from "../ui/SandboxControls"; +import { DeploymentSelect } from "../ui/DeploymentSelect"; +import type { MpaCronTask, TaskFields } from "../adk/mpaCronTasks"; +import { scheduleTypes } from "./mpaSchedule"; +import { MpaIcon } from "./MpaTaskIcons"; + +export function MpaTaskEditor({ + task, + copy, + busy, + error, + onClose, + onSave, +}: { + task?: MpaCronTask; + copy: boolean; + busy: boolean; + error: string; + onClose: () => void; + onSave: (fields: TaskFields) => void; +}) { + const { t } = useTranslation("cronjobs"); + const label = (key: string) => t(`mpa.manage.${key}`); + const initial = task?.schedule; + const [name, setName] = useState( + task ? task.name + (copy ? ` (${label("copy")})` : "") : "", + ); + const [prompt, setPrompt] = useState(task?.prompt || ""); + const [type, setType] = useState(initial?.type || "Daily"); + const [zone, setZone] = useState( + String(initial?.timezone || "Asia/Shanghai"), + ); + const [time, setTime] = useState(String(initial?.time || "09:00")); + const [date, setDate] = useState(() => { + if (!initial?.runAt) return ""; + const d = new Date(String(initial.runAt)); + return new Date(d.getTime() - d.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + }); + const [interval, setInterval] = useState( + String(initial?.intervalSeconds || 3600), + ); + const [days, setDays] = useState(String(initial?.weekdays || "1,2,3,4,5")); + const [monthDays, setMonthDays] = useState(String(initial?.monthDays || "1")); + const [cron, setCron] = useState( + String(initial?.cronExpression || "0 9 * * *"), + ); + const [enabled, setEnabled] = useState(task?.enabled ?? true); + const [channel, setChannel] = useState( + String(task?.delivery?.channel || "Web"), + ); + const [target, setTarget] = useState(String(task?.delivery?.targetId || "")); + const [validation, setValidation] = useState(""); + const composing = useRef(false); + const nameRef = useRef(null); + function save() { + if (busy || composing.current) return; + try { + new Intl.DateTimeFormat("en-US", { timeZone: zone }); + } catch { + setValidation(label("timezoneInvalid")); + return; + } + const schedule: TaskFields["schedule"] = { type, timezone: zone }; + if (type === "Once") { + const at = Date.parse(date); + if (!Number.isFinite(at) || at <= Date.now()) { + setValidation(label("futureTime")); + return; + } + schedule.runAt = new Date(at).toISOString(); + } else if (type === "Interval") { + const seconds = Number(interval); + if (!Number.isInteger(seconds) || seconds < 30 || seconds > 31536000) { + setValidation(label("intervalInvalid")); + return; + } + schedule.intervalSeconds = seconds; + if (initial?.type === type && initial.anchorAt) + schedule.anchorAt = initial.anchorAt; + } else if (type === "Cron") { + if (cron.trim().split(/\s+/).length !== 5) { + setValidation(t("validation.cronFields")); + return; + } + schedule.cronExpression = cron.trim(); + } else { + schedule.time = time; + if (type === "Weekly" || type === "Monthly") { + const value = (type === "Weekly" ? days : monthDays) + .split(",") + .map(Number); + const max = type === "Weekly" ? 7 : 31; + if ( + !value.length || + value.some((n) => !Number.isInteger(n) || n < 1 || n > max) || + new Set(value).size !== value.length + ) { + setValidation(label("daysInvalid")); + return; + } + schedule[type === "Weekly" ? "weekdays" : "monthDays"] = value; + } + } + if ( + !name.trim() || + !prompt.trim() || + (channel === "Feishu" && !target.trim()) + ) { + setValidation(label("required")); + return; + } + const delivery = + task?.delivery?.channel === channel && + String(task.delivery.targetId || "") === target + ? task.delivery + : { + channel, + targetId: channel === "Web" ? null : target.trim(), + receiveIdType: "chat_id", + bestEffort: true, + }; + setValidation(""); + onSave({ + name: name.trim(), + ...(task?.agentId ? { agentId: task.agentId } : {}), + prompt: prompt.trim(), + enabled, + schedule, + delivery, + jitterSeconds: task?.jitterSeconds ?? 0, + timeoutSeconds: task?.timeoutSeconds ?? 3600, + }); + } + return ( + } + className="mpa-dialog" + initialFocusRef={nameRef} + busy={busy} + onClose={onClose} + > +
{ + e.preventDefault(); + save(); + }} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + (composing.current || + e.nativeEvent.isComposing || + e.keyCode === 229) + ) + e.preventDefault(); + }} + > +
+
+ +