From fd746e0eca64ca2fa01a53f1737d32d123004faf Mon Sep 17 00:00:00 2001 From: jinxiaodong <1152716851@qq.com> Date: Sat, 18 Jul 2026 03:58:58 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A1=B9=E7=9B=AE=E5=B9=B6=E6=B8=85=E7=90=86=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除旧的 Go 后端服务、配置与构建相关文件 - 新增 buf 配置、Svelte 开发环境与测试支持 - 添加 V2 API 兼容层与未实现能力提示组件 - 新增脚本服务、工具函数与各类测试用例 - 清理并更新前端依赖与配置文件 - 新增多组 E2E 测试用例与测试桩组件 --- .dockerignore | 43 +- .github/workflows/ci.yml | 44 +- .github/workflows/images.yml | 21 +- .gitignore | 33 +- AGENTS.md | 43 - README.md | 173 +- buf.gen.yaml | 12 + buf.yaml | 3 + bun.lock | 439 ++ bunfig.toml | 2 + cmd/agent-compose-ui-server/main.go | 13 - docker/.env.example | 42 + docker/Dockerfile.scripts | 14 + docker/Dockerfile.web | 31 + docker/README.md | 75 + docker/agent-compose.env.example | 28 + docker/docker-compose.full.yml | 73 + docker/docker-compose.yml | 53 + docker/nginx/default.conf.template | 54 + .../e2e-yml10-20260716t014615207z/01-case.yml | 12 + .../e2e-yml10-20260716t014710785z/01-case.yml | 12 + .../e2e-yml10-20260716t014932476z/01-case.yml | 12 + .../e2e-yml10-20260716t015004818z/01-case.yml | 12 + .../e2e-yml10-20260716t015004818z/02-case.yml | 12 + .../e2e-yml10-20260716t015004818z/03-case.yml | 12 + .../e2e-yml10-20260716t015004818z/04-case.yml | 12 + .../e2e-yml10-20260716t015050955z/01-case.yml | 12 + .../e2e-yml10-20260716t015225860z/01-case.yml | 12 + .../e2e-yml10-20260716t015349578z/01-case.yml | 12 + .../e2e-yml10-20260716t015349578z/02-case.yml | 12 + .../e2e-yml10-20260716t015349578z/03-case.yml | 12 + .../e2e-yml10-20260716t015349578z/04-case.yml | 12 + .../e2e-yml10-20260716t015349578z/05-case.yml | 12 + .../e2e-yml10-20260716t015349578z/06-case.yml | 12 + .../e2e-yml10-20260716t015349578z/07-case.yml | 19 + .../e2e-yml10-20260716t015349578z/08-case.yml | 13 + .../e2e-yml10-20260716t015349578z/09-case.yml | 12 + .../e2e-yml10-20260716t015349578z/10-case.yml | 12 + .../full-yaml/agent-compose.expanded.yml | 79 + e2e/fixtures/full-yaml/agent-compose.yml | 73 + .../full-yaml/build-context/Dockerfile | 3 + e2e/fixtures/full-yaml/scheduler.js | 9 + .../full-yaml/workspace/workspace-marker.txt | 1 + e2e/real-data/api.test.ts | 73 + e2e/real-data/api.ts | 395 + e2e/real-data/browser.ts | 127 + e2e/real-data/fixtures.ts | 76 + e2e/real-data/full-yaml-persistent.ts | 167 + e2e/real-data/report.test.ts | 56 + e2e/real-data/report.ts | 138 + e2e/real-data/run.ts | 46 + e2e/real-data/yml10-persistent.ts | 158 + go.mod | 23 - go.sum | 37 - index.html | 11 +- internal/app/server.go | 103 - internal/app/server_test.go | 26 - internal/auth/auth.go | 153 - internal/auth/auth_handlers.go | 153 - internal/auth/main_test.go | 86 - internal/auth/paths.go | 52 - internal/auth/response.go | 49 - internal/auth/session.go | 115 - internal/config/config.go | 18 - internal/proxy/proxy.go | 38 - nginx/10-normalize-jupyter-proxy-base.envsh | 27 - nginx/Dockerfile | 31 - nginx/agent-compose-entrypoint.sh | 37 - nginx/nginx.conf | 137 - nginx/nginx.main | 33 - package-lock.json | 1465 +++- package.json | 60 +- playwright.config.ts | 13 + proto/agentcompose/v2/agentcompose.proto | 1713 +++++ script-service/app.mjs | 197 + script-service/app.test.mjs | 112 + script-service/errors.mjs | 13 + script-service/metadata.mjs | 117 + script-service/metadata.test.mjs | 58 + script-service/paths.mjs | 103 + script-service/paths.test.mjs | 103 + script-service/server.mjs | 22 + script-service/storage.mjs | 128 + script-service/storage.test.mjs | 39 + scripts/dev.mjs | 49 + scripts/dev.test.mjs | 18 + scripts/normalize-generated-eof.mjs | 20 - scripts/svelte-test-preload.mjs | 20 + src/App.svelte | 579 +- src/api/agents.ts | 279 - src/api/auth.ts | 49 - src/api/client.ts | 27 - src/api/config.ts | 321 - src/api/dashboard.ts | 35 - src/api/exec.ts | 117 - src/api/health.ts | 52 - src/api/http.ts | 29 - src/api/loaders.ts | 578 -- src/api/runs.ts | 21 - src/api/sessions.ts | 111 - src/app.css | 58 + src/components/AntIcon.svelte | 34 - src/components/Resizer.svelte | 85 + src/components/RuntimeCommandTerminal.svelte | 412 - src/components/ScriptLineActions.test.js | 44 + src/components/SessionOutputPanel.svelte | 364 - src/components/Sidebar.svelte | 584 ++ src/components/Sidebar.test.js | 101 + src/components/StatusIndicator.svelte | 27 + src/components/Toast.svelte | 61 + src/components/Toolbar.svelte | 1142 +++ src/components/ToolbarImageBuild.test.js | 42 + .../ToolbarLatestRun.component.test.ts | 516 ++ src/components/ToolbarScripts.test.js | 11 + src/components/V2Unavailable.svelte | 22 + src/components/V2Unavailable.test.ts | 20 + src/components/YamlEditor.svelte | 517 ++ src/components/YamlEditor.test.js | 87 + .../scripts/ScriptCreateModal.svelte | 166 + .../scripts/ScriptCreateModal.test.js | 9 + src/components/scripts/ScriptEditor.svelte | 128 + src/components/scripts/ScriptEditor.test.js | 11 + .../scripts/ScriptLineActions.svelte | 54 + .../scripts/ScriptPanel.component.test.ts | 22 + src/components/scripts/ScriptPanel.svelte | 252 + src/components/scripts/ScriptPanel.test.js | 53 + .../scripts/ScriptReferenceModal.svelte | 168 + .../scripts/ScriptReferenceModal.test.js | 25 + src/components/scripts/ScriptTree.svelte | 224 + src/components/scripts/ScriptTree.test.js | 69 + .../settings/CapabilityCatalogPanel.svelte | 111 + .../settings/CapabilityCatalogPanel.test.ts | 112 + .../settings/CapabilityGatewayPanel.svelte | 122 + .../settings/CapabilityGatewayPanel.test.ts | 135 + src/components/settings/GlobalEnvPanel.svelte | 205 + .../settings/GlobalEnvPanel.test.ts | 200 + .../agentcompose/v1/agentcompose_connect.ts | 512 ++ src/gen/agentcompose/v1/agentcompose_pb.ts | 5356 +++++++++++++ .../agentcompose/v2/agentcompose_connect.ts | 46 +- src/gen/agentcompose/v2/agentcompose_pb.ts | 691 +- src/gen/health/v1/health_connect.ts | 1 + src/gen/health/v1/health_pb.ts | 1 + src/lib/agent-owned-executions.test.ts | 101 + src/lib/agent-owned-executions.ts | 220 + src/lib/agent-run-transcript.test.ts | 134 + src/lib/agent-run-transcript.ts | 135 + src/lib/caches.ts | 27 + src/lib/clipboard.test.ts | 32 + src/lib/clipboard.ts | 26 + src/lib/event-sandbox-links.test.ts | 47 + src/lib/event-sandbox-links.ts | 67 + src/lib/full-yaml-e2e-fixture.test.ts | 46 + src/lib/image-management.test.ts | 42 + src/lib/image-management.ts | 48 + src/lib/images.test.js | 21 + src/lib/images.ts | 33 + src/lib/project-dependency-preflight.test.ts | 139 + src/lib/project-dependency-preflight.ts | 84 + src/lib/project-image-build.test.ts | 227 + src/lib/project-image-build.ts | 225 + src/lib/projects.test.js | 50 + src/lib/projects.ts | 27 + src/lib/rpc.ts | 50 + src/lib/run-confirmed-evidence.test.ts | 118 + src/lib/run-confirmed-evidence.ts | 194 + src/lib/run-controls.test.js | 45 + src/lib/run-controls.ts | 53 + src/lib/run-history.test.js | 31 + src/lib/run-history.ts | 29 + src/lib/run-log-window.test.js | 55 + src/lib/run-log-window.ts | 96 + src/lib/run-scheduler-evidence.test.ts | 89 + src/lib/run-scheduler-evidence.ts | 166 + src/lib/runtime-inventory.test.js | 56 + src/lib/runtime-inventory.ts | 89 + src/lib/runtime-timeline.test.js | 139 + src/lib/runtime-timeline.ts | 177 + src/lib/sandbox-detail.test.js | 105 + src/lib/sandbox-detail.ts | 123 + src/lib/sandbox-inventory.test.ts | 40 + src/lib/sandbox-inventory.ts | 52 + src/lib/sandbox-watch.test.ts | 76 + src/lib/sandbox-watch.ts | 71 + src/lib/sandboxes.test.js | 17 + src/lib/sandboxes.ts | 33 + src/lib/scheduler-execution-links.test.ts | 86 + src/lib/scheduler-execution-links.ts | 131 + src/lib/scheduler-full-execution.test.ts | 351 + src/lib/scheduler-full-execution.ts | 278 + src/lib/scheduler-full-timeline.test.ts | 97 + src/lib/scheduler-full-timeline.ts | 243 + src/lib/scheduler-run-event.test.ts | 13 + src/lib/scheduler-run-event.ts | 8 + src/lib/scheduler-run-timeline.test.ts | 34 + src/lib/scheduler-run-timeline.ts | 71 + src/lib/scripts/api.test.js | 103 + src/lib/scripts/api.ts | 121 + src/lib/scripts/editor-actions.test.js | 144 + src/lib/scripts/editor-actions.ts | 193 + src/lib/scripts/env-interpolation.test.js | 91 + src/lib/scripts/env-interpolation.ts | 71 + src/lib/scripts/global-env.ts | 39 + src/lib/scripts/paths.test.js | 28 + src/lib/scripts/paths.ts | 15 + src/lib/scripts/project-lifecycle.test.js | 103 + src/lib/scripts/project-lifecycle.ts | 30 + src/lib/scripts/references.test.js | 73 + src/lib/scripts/references.ts | 150 + src/lib/scripts/request-pipeline.test.js | 155 + src/lib/scripts/request-pipeline.ts | 43 + src/lib/scripts/tree.test.js | 65 + src/lib/scripts/tree.ts | 53 + src/lib/scripts/types.ts | 29 + src/lib/scripts/workspace.svelte.ts | 229 + src/lib/scripts/workspace.test.js | 263 + src/lib/sha256.ts | 60 + src/lib/stores.svelte.ts | 650 ++ src/lib/stores.test.ts | 127 + src/lib/svelte-warning-regressions.test.js | 24 + src/lib/toolbar-actions.test.js | 1320 ++++ src/lib/toolbar-actions.ts | 752 ++ src/lib/types.ts | 29 + src/lib/v2-audit.test.js | 44 + src/lib/v2-capabilities.ts | 8 + src/lib/v2-generated-api.test.ts | 15 + src/lib/v2-only.test.js | 36 + src/lib/volumes.ts | 8 + src/lib/yaml-run-batch-store.test.js | 164 + src/lib/yaml-run-batch.svelte.ts | 169 + src/lib/yaml-run-batch.test.js | 93 + src/lib/yaml-run-batch.ts | 174 + src/lib/yaml-workflow-matrix.test.ts | 101 + src/lib/yaml.ts | 149 + src/main.ts | 9 +- src/modals/ImageOperations.test.js | 95 + src/modals/PullImageModal.svelte | 40 + src/modals/RemoveImageModal.component.test.ts | 45 + src/modals/RemoveImageModal.svelte | 104 + src/modals/RunAgentModal.component.test.ts | 110 + src/modals/RunAgentModal.svelte | 264 + src/modals/RunAgentModal.test.js | 50 + src/modals/build-image.ts | 55 + src/model/agents.ts | 33 - src/model/runs.ts | 181 - src/model/runtime.ts | 1 - src/model/sandbox-policy.ts | 19 - src/model/session-output.ts | 92 - src/pages/AgentsPage.svelte | 728 -- src/pages/AutomationTasksPage.svelte | 1132 --- src/pages/CacheListView.svelte | 67 + src/pages/CacheVolumeViews.test.ts | 387 + src/pages/Dashboard.component.test.ts | 72 + src/pages/Dashboard.svelte | 96 + src/pages/DebugRunPage.svelte | 271 - src/pages/EventDetailPage.svelte | 1433 ---- .../EventSandboxDetailPage.component.test.ts | 100 + src/pages/EventSandboxDetailPage.svelte | 150 + src/pages/ImageListView.component.test.ts | 85 + src/pages/ImageListView.svelte | 225 + src/pages/ImageListView.test.js | 48 + src/pages/LoginPage.svelte | 100 - src/pages/ProjectWorkspace.svelte | 52 + src/pages/RunsPage.svelte | 4613 ----------- src/pages/SettingsPage.svelte | 1034 --- src/pages/SystemSettings.svelte | 103 + src/pages/VolumeListView.svelte | 80 + src/pages/WorkbenchPage.svelte | 176 - src/pages/session/FileBrowser.svelte | 43 + .../session/SessionTerminal.component.test.ts | 55 + src/pages/session/SessionTerminal.svelte | 118 + src/pages/session/file-browser.test.js | 134 + src/pages/session/file-browser.ts | 105 + src/pages/v2-production-wiring.test.js | 32 + src/pages/v2-unavailable-pages.test.js | 36 + src/paths.ts | 32 - src/styles.css | 6747 ----------------- src/test/fixtures/ScriptEditorStub.svelte | 5 + src/time.ts | 17 - src/url.ts | 22 - src/views/runtime/AgentListView.svelte | 351 + .../AgentRunListView.component.test.ts | 208 + src/views/runtime/AgentRunListView.svelte | 281 + .../runtime/LatestRunView.component.test.ts | 193 + src/views/runtime/LatestRunView.svelte | 186 + src/views/runtime/LoaderRunDetailView.svelte | 5 + src/views/runtime/LoaderRunDetailView.test.js | 2 + src/views/runtime/LoaderRunListView.svelte | 5 + src/views/runtime/LoaderRunListView.test.js | 2 + .../ProjectRuntimeView.component.test.ts | 73 + src/views/runtime/ProjectRuntimeView.svelte | 102 + src/views/runtime/ProjectRuntimeView.test.js | 7 + .../runtime/RunDetailView.component.test.ts | 515 ++ src/views/runtime/RunDetailView.svelte | 92 + src/views/runtime/RunDetailView.test.js | 119 + .../RunExecutionProcess.component.test.ts | 187 + src/views/runtime/RunExecutionProcess.svelte | 576 ++ ...unExecutionTimelineEntry.component.test.ts | 55 + .../runtime/RunExecutionTimelineEntry.svelte | 67 + .../RuntimeBreadcrumb.component.test.ts | 74 + src/views/runtime/RuntimeBreadcrumb.svelte | 80 + .../runtime/RuntimeBreadcrumbUsage.test.js | 23 + .../SandboxDetailView.component.test.ts | 595 ++ src/views/runtime/SandboxDetailView.svelte | 458 ++ .../runtime/SandboxListView.component.test.ts | 377 + src/views/runtime/SandboxListView.svelte | 220 + src/views/runtime/SandboxListView.test.js | 35 + .../SchedulerListView.component.test.ts | 278 + src/views/runtime/SchedulerListView.svelte | 209 + src/views/runtime/SchedulerListView.test.js | 21 + .../SchedulerRunDetailView.component.test.ts | 366 + .../runtime/SchedulerRunDetailView.svelte | 163 + src/views/runtime/SessionDetailView.svelte | 5 + src/views/runtime/TimelineEntry.svelte | 136 + src/vite-env.d.ts | 8 + svelte.config.js | 2 + test/components/Sidebar.test.ts | 250 + .../StatusIndicator.component.test.ts | 23 + test/fixtures/LatestRunAgentCardStub.svelte | 5 + test/fixtures/RunDetailViewStub.svelte | 5 + test/fixtures/RunExecutionProcessStub.svelte | 12 + test/fixtures/SandboxDetailViewStub.svelte | 5 + test/fixtures/v2-capability-matrix.md | 40 + test/fixtures/yaml-workflow-scenarios.ts | 38 + test/helpers/mock-rpc.ts | 28 + test/pages/SystemSettings.test.ts | 126 + test/setup.ts | 8 + test/stores.test.ts | 337 + test/views/AgentListView.test.ts | 220 + tsconfig.json | 20 +- vite.config.test.js | 8 + vite.config.ts | 69 +- vitest.config.ts | 26 + 332 files changed, 39109 insertions(+), 21661 deletions(-) delete mode 100644 AGENTS.md create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 bun.lock create mode 100644 bunfig.toml delete mode 100644 cmd/agent-compose-ui-server/main.go create mode 100644 docker/.env.example create mode 100644 docker/Dockerfile.scripts create mode 100644 docker/Dockerfile.web create mode 100644 docker/README.md create mode 100644 docker/agent-compose.env.example create mode 100644 docker/docker-compose.full.yml create mode 100644 docker/docker-compose.yml create mode 100644 docker/nginx/default.conf.template create mode 100644 e2e/fixtures/e2e-yml10-20260716t014615207z/01-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t014710785z/01-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t014932476z/01-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015004818z/01-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015004818z/02-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015004818z/03-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015004818z/04-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015050955z/01-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015225860z/01-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/01-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/02-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/03-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/04-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/05-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/06-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/07-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/08-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/09-case.yml create mode 100644 e2e/fixtures/e2e-yml10-20260716t015349578z/10-case.yml create mode 100644 e2e/fixtures/full-yaml/agent-compose.expanded.yml create mode 100644 e2e/fixtures/full-yaml/agent-compose.yml create mode 100644 e2e/fixtures/full-yaml/build-context/Dockerfile create mode 100644 e2e/fixtures/full-yaml/scheduler.js create mode 100644 e2e/fixtures/full-yaml/workspace/workspace-marker.txt create mode 100644 e2e/real-data/api.test.ts create mode 100644 e2e/real-data/api.ts create mode 100644 e2e/real-data/browser.ts create mode 100644 e2e/real-data/fixtures.ts create mode 100644 e2e/real-data/full-yaml-persistent.ts create mode 100644 e2e/real-data/report.test.ts create mode 100644 e2e/real-data/report.ts create mode 100644 e2e/real-data/run.ts create mode 100644 e2e/real-data/yml10-persistent.ts delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/app/server.go delete mode 100644 internal/app/server_test.go delete mode 100644 internal/auth/auth.go delete mode 100644 internal/auth/auth_handlers.go delete mode 100644 internal/auth/main_test.go delete mode 100644 internal/auth/paths.go delete mode 100644 internal/auth/response.go delete mode 100644 internal/auth/session.go delete mode 100644 internal/config/config.go delete mode 100644 internal/proxy/proxy.go delete mode 100644 nginx/10-normalize-jupyter-proxy-base.envsh delete mode 100644 nginx/Dockerfile delete mode 100644 nginx/agent-compose-entrypoint.sh delete mode 100644 nginx/nginx.conf delete mode 100644 nginx/nginx.main create mode 100644 playwright.config.ts create mode 100644 proto/agentcompose/v2/agentcompose.proto create mode 100644 script-service/app.mjs create mode 100644 script-service/app.test.mjs create mode 100644 script-service/errors.mjs create mode 100644 script-service/metadata.mjs create mode 100644 script-service/metadata.test.mjs create mode 100644 script-service/paths.mjs create mode 100644 script-service/paths.test.mjs create mode 100644 script-service/server.mjs create mode 100644 script-service/storage.mjs create mode 100644 script-service/storage.test.mjs create mode 100644 scripts/dev.mjs create mode 100644 scripts/dev.test.mjs delete mode 100644 scripts/normalize-generated-eof.mjs create mode 100644 scripts/svelte-test-preload.mjs delete mode 100644 src/api/agents.ts delete mode 100644 src/api/auth.ts delete mode 100644 src/api/client.ts delete mode 100644 src/api/config.ts delete mode 100644 src/api/dashboard.ts delete mode 100644 src/api/exec.ts delete mode 100644 src/api/health.ts delete mode 100644 src/api/http.ts delete mode 100644 src/api/loaders.ts delete mode 100644 src/api/runs.ts delete mode 100644 src/api/sessions.ts create mode 100644 src/app.css delete mode 100644 src/components/AntIcon.svelte create mode 100644 src/components/Resizer.svelte delete mode 100644 src/components/RuntimeCommandTerminal.svelte create mode 100644 src/components/ScriptLineActions.test.js delete mode 100644 src/components/SessionOutputPanel.svelte create mode 100644 src/components/Sidebar.svelte create mode 100644 src/components/Sidebar.test.js create mode 100644 src/components/StatusIndicator.svelte create mode 100644 src/components/Toast.svelte create mode 100644 src/components/Toolbar.svelte create mode 100644 src/components/ToolbarImageBuild.test.js create mode 100644 src/components/ToolbarLatestRun.component.test.ts create mode 100644 src/components/ToolbarScripts.test.js create mode 100644 src/components/V2Unavailable.svelte create mode 100644 src/components/V2Unavailable.test.ts create mode 100644 src/components/YamlEditor.svelte create mode 100644 src/components/YamlEditor.test.js create mode 100644 src/components/scripts/ScriptCreateModal.svelte create mode 100644 src/components/scripts/ScriptCreateModal.test.js create mode 100644 src/components/scripts/ScriptEditor.svelte create mode 100644 src/components/scripts/ScriptEditor.test.js create mode 100644 src/components/scripts/ScriptLineActions.svelte create mode 100644 src/components/scripts/ScriptPanel.component.test.ts create mode 100644 src/components/scripts/ScriptPanel.svelte create mode 100644 src/components/scripts/ScriptPanel.test.js create mode 100644 src/components/scripts/ScriptReferenceModal.svelte create mode 100644 src/components/scripts/ScriptReferenceModal.test.js create mode 100644 src/components/scripts/ScriptTree.svelte create mode 100644 src/components/scripts/ScriptTree.test.js create mode 100644 src/components/settings/CapabilityCatalogPanel.svelte create mode 100644 src/components/settings/CapabilityCatalogPanel.test.ts create mode 100644 src/components/settings/CapabilityGatewayPanel.svelte create mode 100644 src/components/settings/CapabilityGatewayPanel.test.ts create mode 100644 src/components/settings/GlobalEnvPanel.svelte create mode 100644 src/components/settings/GlobalEnvPanel.test.ts create mode 100644 src/gen/agentcompose/v1/agentcompose_connect.ts create mode 100644 src/gen/agentcompose/v1/agentcompose_pb.ts create mode 100644 src/lib/agent-owned-executions.test.ts create mode 100644 src/lib/agent-owned-executions.ts create mode 100644 src/lib/agent-run-transcript.test.ts create mode 100644 src/lib/agent-run-transcript.ts create mode 100644 src/lib/caches.ts create mode 100644 src/lib/clipboard.test.ts create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/event-sandbox-links.test.ts create mode 100644 src/lib/event-sandbox-links.ts create mode 100644 src/lib/full-yaml-e2e-fixture.test.ts create mode 100644 src/lib/image-management.test.ts create mode 100644 src/lib/image-management.ts create mode 100644 src/lib/images.test.js create mode 100644 src/lib/images.ts create mode 100644 src/lib/project-dependency-preflight.test.ts create mode 100644 src/lib/project-dependency-preflight.ts create mode 100644 src/lib/project-image-build.test.ts create mode 100644 src/lib/project-image-build.ts create mode 100644 src/lib/projects.test.js create mode 100644 src/lib/projects.ts create mode 100644 src/lib/rpc.ts create mode 100644 src/lib/run-confirmed-evidence.test.ts create mode 100644 src/lib/run-confirmed-evidence.ts create mode 100644 src/lib/run-controls.test.js create mode 100644 src/lib/run-controls.ts create mode 100644 src/lib/run-history.test.js create mode 100644 src/lib/run-history.ts create mode 100644 src/lib/run-log-window.test.js create mode 100644 src/lib/run-log-window.ts create mode 100644 src/lib/run-scheduler-evidence.test.ts create mode 100644 src/lib/run-scheduler-evidence.ts create mode 100644 src/lib/runtime-inventory.test.js create mode 100644 src/lib/runtime-inventory.ts create mode 100644 src/lib/runtime-timeline.test.js create mode 100644 src/lib/runtime-timeline.ts create mode 100644 src/lib/sandbox-detail.test.js create mode 100644 src/lib/sandbox-detail.ts create mode 100644 src/lib/sandbox-inventory.test.ts create mode 100644 src/lib/sandbox-inventory.ts create mode 100644 src/lib/sandbox-watch.test.ts create mode 100644 src/lib/sandbox-watch.ts create mode 100644 src/lib/sandboxes.test.js create mode 100644 src/lib/sandboxes.ts create mode 100644 src/lib/scheduler-execution-links.test.ts create mode 100644 src/lib/scheduler-execution-links.ts create mode 100644 src/lib/scheduler-full-execution.test.ts create mode 100644 src/lib/scheduler-full-execution.ts create mode 100644 src/lib/scheduler-full-timeline.test.ts create mode 100644 src/lib/scheduler-full-timeline.ts create mode 100644 src/lib/scheduler-run-event.test.ts create mode 100644 src/lib/scheduler-run-event.ts create mode 100644 src/lib/scheduler-run-timeline.test.ts create mode 100644 src/lib/scheduler-run-timeline.ts create mode 100644 src/lib/scripts/api.test.js create mode 100644 src/lib/scripts/api.ts create mode 100644 src/lib/scripts/editor-actions.test.js create mode 100644 src/lib/scripts/editor-actions.ts create mode 100644 src/lib/scripts/env-interpolation.test.js create mode 100644 src/lib/scripts/env-interpolation.ts create mode 100644 src/lib/scripts/global-env.ts create mode 100644 src/lib/scripts/paths.test.js create mode 100644 src/lib/scripts/paths.ts create mode 100644 src/lib/scripts/project-lifecycle.test.js create mode 100644 src/lib/scripts/project-lifecycle.ts create mode 100644 src/lib/scripts/references.test.js create mode 100644 src/lib/scripts/references.ts create mode 100644 src/lib/scripts/request-pipeline.test.js create mode 100644 src/lib/scripts/request-pipeline.ts create mode 100644 src/lib/scripts/tree.test.js create mode 100644 src/lib/scripts/tree.ts create mode 100644 src/lib/scripts/types.ts create mode 100644 src/lib/scripts/workspace.svelte.ts create mode 100644 src/lib/scripts/workspace.test.js create mode 100644 src/lib/sha256.ts create mode 100644 src/lib/stores.svelte.ts create mode 100644 src/lib/stores.test.ts create mode 100644 src/lib/svelte-warning-regressions.test.js create mode 100644 src/lib/toolbar-actions.test.js create mode 100644 src/lib/toolbar-actions.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/v2-audit.test.js create mode 100644 src/lib/v2-capabilities.ts create mode 100644 src/lib/v2-generated-api.test.ts create mode 100644 src/lib/v2-only.test.js create mode 100644 src/lib/volumes.ts create mode 100644 src/lib/yaml-run-batch-store.test.js create mode 100644 src/lib/yaml-run-batch.svelte.ts create mode 100644 src/lib/yaml-run-batch.test.js create mode 100644 src/lib/yaml-run-batch.ts create mode 100644 src/lib/yaml-workflow-matrix.test.ts create mode 100644 src/lib/yaml.ts create mode 100644 src/modals/ImageOperations.test.js create mode 100644 src/modals/PullImageModal.svelte create mode 100644 src/modals/RemoveImageModal.component.test.ts create mode 100644 src/modals/RemoveImageModal.svelte create mode 100644 src/modals/RunAgentModal.component.test.ts create mode 100644 src/modals/RunAgentModal.svelte create mode 100644 src/modals/RunAgentModal.test.js create mode 100644 src/modals/build-image.ts delete mode 100644 src/model/agents.ts delete mode 100644 src/model/runs.ts delete mode 100644 src/model/runtime.ts delete mode 100644 src/model/sandbox-policy.ts delete mode 100644 src/model/session-output.ts delete mode 100644 src/pages/AgentsPage.svelte delete mode 100644 src/pages/AutomationTasksPage.svelte create mode 100644 src/pages/CacheListView.svelte create mode 100644 src/pages/CacheVolumeViews.test.ts create mode 100644 src/pages/Dashboard.component.test.ts create mode 100644 src/pages/Dashboard.svelte delete mode 100644 src/pages/DebugRunPage.svelte delete mode 100644 src/pages/EventDetailPage.svelte create mode 100644 src/pages/EventSandboxDetailPage.component.test.ts create mode 100644 src/pages/EventSandboxDetailPage.svelte create mode 100644 src/pages/ImageListView.component.test.ts create mode 100644 src/pages/ImageListView.svelte create mode 100644 src/pages/ImageListView.test.js delete mode 100644 src/pages/LoginPage.svelte create mode 100644 src/pages/ProjectWorkspace.svelte delete mode 100644 src/pages/RunsPage.svelte delete mode 100644 src/pages/SettingsPage.svelte create mode 100644 src/pages/SystemSettings.svelte create mode 100644 src/pages/VolumeListView.svelte delete mode 100644 src/pages/WorkbenchPage.svelte create mode 100644 src/pages/session/FileBrowser.svelte create mode 100644 src/pages/session/SessionTerminal.component.test.ts create mode 100644 src/pages/session/SessionTerminal.svelte create mode 100644 src/pages/session/file-browser.test.js create mode 100644 src/pages/session/file-browser.ts create mode 100644 src/pages/v2-production-wiring.test.js create mode 100644 src/pages/v2-unavailable-pages.test.js delete mode 100644 src/paths.ts delete mode 100644 src/styles.css create mode 100644 src/test/fixtures/ScriptEditorStub.svelte delete mode 100644 src/time.ts delete mode 100644 src/url.ts create mode 100644 src/views/runtime/AgentListView.svelte create mode 100644 src/views/runtime/AgentRunListView.component.test.ts create mode 100644 src/views/runtime/AgentRunListView.svelte create mode 100644 src/views/runtime/LatestRunView.component.test.ts create mode 100644 src/views/runtime/LatestRunView.svelte create mode 100644 src/views/runtime/LoaderRunDetailView.svelte create mode 100644 src/views/runtime/LoaderRunDetailView.test.js create mode 100644 src/views/runtime/LoaderRunListView.svelte create mode 100644 src/views/runtime/LoaderRunListView.test.js create mode 100644 src/views/runtime/ProjectRuntimeView.component.test.ts create mode 100644 src/views/runtime/ProjectRuntimeView.svelte create mode 100644 src/views/runtime/ProjectRuntimeView.test.js create mode 100644 src/views/runtime/RunDetailView.component.test.ts create mode 100644 src/views/runtime/RunDetailView.svelte create mode 100644 src/views/runtime/RunDetailView.test.js create mode 100644 src/views/runtime/RunExecutionProcess.component.test.ts create mode 100644 src/views/runtime/RunExecutionProcess.svelte create mode 100644 src/views/runtime/RunExecutionTimelineEntry.component.test.ts create mode 100644 src/views/runtime/RunExecutionTimelineEntry.svelte create mode 100644 src/views/runtime/RuntimeBreadcrumb.component.test.ts create mode 100644 src/views/runtime/RuntimeBreadcrumb.svelte create mode 100644 src/views/runtime/RuntimeBreadcrumbUsage.test.js create mode 100644 src/views/runtime/SandboxDetailView.component.test.ts create mode 100644 src/views/runtime/SandboxDetailView.svelte create mode 100644 src/views/runtime/SandboxListView.component.test.ts create mode 100644 src/views/runtime/SandboxListView.svelte create mode 100644 src/views/runtime/SandboxListView.test.js create mode 100644 src/views/runtime/SchedulerListView.component.test.ts create mode 100644 src/views/runtime/SchedulerListView.svelte create mode 100644 src/views/runtime/SchedulerListView.test.js create mode 100644 src/views/runtime/SchedulerRunDetailView.component.test.ts create mode 100644 src/views/runtime/SchedulerRunDetailView.svelte create mode 100644 src/views/runtime/SessionDetailView.svelte create mode 100644 src/views/runtime/TimelineEntry.svelte create mode 100644 src/vite-env.d.ts create mode 100644 svelte.config.js create mode 100644 test/components/Sidebar.test.ts create mode 100644 test/components/StatusIndicator.component.test.ts create mode 100644 test/fixtures/LatestRunAgentCardStub.svelte create mode 100644 test/fixtures/RunDetailViewStub.svelte create mode 100644 test/fixtures/RunExecutionProcessStub.svelte create mode 100644 test/fixtures/SandboxDetailViewStub.svelte create mode 100644 test/fixtures/v2-capability-matrix.md create mode 100644 test/fixtures/yaml-workflow-scenarios.ts create mode 100644 test/helpers/mock-rpc.ts create mode 100644 test/pages/SystemSettings.test.ts create mode 100644 test/setup.ts create mode 100644 test/stores.test.ts create mode 100644 test/views/AgentListView.test.ts create mode 100644 vite.config.test.js create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore index 30c96d5..c3b257e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,32 @@ -node_modules/ -dist/ -dist.bak.*/ -frontend.pid -frontend.node.pid -frontend.log -frontend.tar.gz -.git/ -.github/ -.env -.env.* +# Build context is the repo root (the project itself). Exclude everything the +# image builds don't need, to speed up builds and avoid baking in secrets. +# NOTE: do NOT exclude docker/ or script-service/ - the Dockerfiles reference them. + +# VCS +.git +.gitignore + +# Deps & build artifacts (regenerated inside the image) +**/node_modules +**/dist +**/.svelte-kit + +# Tests, e2e, local runtime data - not needed in the runtime image +test +e2e +test-results +data +# Full-stack compose runtime data/config (self-contained under docker/) +docker/data +docker/agent-compose.env + +# Test files anywhere +**/*.test.* +**/*.spec.* + +# Secrets - never bake into images (mounted as volumes at runtime instead) +**/.env +**/.env.* + +# Misc +**/.DS_Store diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a14aacf..2b0279e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,17 +17,49 @@ jobs: build: name: Test and build runs-on: ubuntu-latest + env: + # The UI formats run timestamps in local time and the component tests + # assert against UTC+8 values (the project's zh-CN target locale). Run the + # suite in Asia/Shanghai so the runner's default UTC doesn't trip them. + TZ: Asia/Shanghai steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - - uses: actions/setup-go@v5 + + # bun:test drives the .test.js/.test.mjs unit suite and the e2e unit tests. + # Pinned to 1.x to match the oven/bun:1-alpine base used by the scripts image. + - uses: oven-sh/setup-bun@v2 with: - go-version-file: go.mod - cache: true + bun-version: 1 + - run: npm ci - - run: go test ./... - - run: npm run build:ui - - run: docker build -f nginx/Dockerfile -t agent-compose-ui:test . + + - name: Verify generated protobuf is up to date + run: | + npm run gen + git diff --exit-code -- src/gen + + - name: Type check + run: npm run check + + - name: Unit tests + run: npm test + + - name: E2E unit tests + run: npm run test:e2e:unit + + - name: Component tests + run: npm run test:component + + - name: Build SPA + run: npm run build + + - name: Build web image + run: docker build -f docker/Dockerfile.web -t agent-compose-web:ci . + + - name: Build scripts image + run: docker build -f docker/Dockerfile.scripts -t agent-compose-scripts:ci . diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 9fd4e68..dca1a66 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -16,13 +16,20 @@ concurrency: group: image-${{ github.ref }} cancel-in-progress: false -env: - IMAGE: ghcr.io/${{ github.repository_owner }}/agent-compose-ui - jobs: image: - name: Build and push UI image + name: Build and push ${{ matrix.suffix }} image runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The deployable stack (docker/docker-compose*.yml) builds both images; + # publish them so deployments can pull instead of building locally. + include: + - dockerfile: docker/Dockerfile.web + suffix: agent-compose-ui + - dockerfile: docker/Dockerfile.scripts + suffix: agent-compose-scripts steps: - uses: actions/checkout@v4 - uses: docker/setup-qemu-action@v3 @@ -32,7 +39,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE }} + images: ghcr.io/${{ github.repository_owner }}/${{ matrix.suffix }} tags: | type=ref,event=branch type=semver,pattern={{version}} @@ -50,8 +57,10 @@ jobs: uses: docker/build-push-action@v6 with: context: . - file: nginx/Dockerfile + file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.suffix }} + cache-to: type=gha,scope=${{ matrix.suffix }},mode=max diff --git a/.gitignore b/.gitignore index 2490d9a..4f325a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,11 @@ -# Dependencies +.superpowers/ node_modules/ - -# Build output dist/ -dist.bak.*/ -frontend.tar.gz -frontend.pid -frontend.node.pid - -# Vite / Svelte caches -.vite/ -.svelte-kit/ - -# Local env +/data/scripts/ +/e2e/reports/ .env -.env.* -!.env.example - -# Logs -*.log -npm-debug.log* - -# OS / editor -.DS_Store -Thumbs.db -*.swp -.idea/ +test-results/ +docker/.env +# Full-stack compose runtime data/config (self-contained under docker/) +docker/data/ +docker/agent-compose.env diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index f5e7067..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,43 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -This repository contains a Svelte 5 + Vite UI and a small Go UI server/proxy. Frontend code lives in `src/`: pages in `src/pages/`, shared UI in `src/components/`, API wrappers in `src/api/`, and domain helpers in `src/model/`. Go code lives under `cmd/agent-compose-ui-server/` and `internal/`. Runtime image and nginx configuration live in `nginx/`. - -## Build, Test, and Development Commands - -- `npm run dev:ui`: start Vite on `0.0.0.0:5174`, proxying daemon calls to `http://127.0.0.1:7410`. -- `npm run build:ui`: build the Svelte app into `dist/`. -- `npm run check:ui`: run the same Vite production build as the frontend check. -- `npm run check:server` or `go test ./...`: run all Go tests. -- `npm run build:image`: build the local nginx-based Docker image. - -Run `npm ci` before frontend work. Set `AGENT_COMPOSE_BASE` when hosting below `/`. - -## Coding Style & Naming Conventions - -Use TypeScript strict mode. Follow existing frontend style: two-space indentation, single quotes, semicolons, PascalCase Svelte components, and camelCase functions/variables. Keep Svelte markup declarative; move non-trivial data shaping, formatting, and state transitions into named functions. - -For Go, use `gofmt`, package-local tests, and idiomatic `TestName` functions. Prefer small handlers and helpers with explicit inputs over package-level mutable state. - -## Module Boundaries - -Keep route-level orchestration in `src/pages/`; split large page sections into `src/components/` before adding unrelated behavior. Components should receive data and callbacks through props. - -Use `src/api/` only for HTTP/RPC calls, request options, and raw response adaptation. Put shared domain types, derived values, formatters, and normalization logic in `src/model/` or focused utility modules. Do not import Svelte components from `src/api/` or `src/model/`. - -Keep Go HTTP setup in `internal/app`, auth/session/OAuth logic in `internal/auth`, reverse proxy behavior in `internal/proxy`, and environment/default handling in `internal/config`. - -## Testing Guidelines - -Use Go `testing` plus `httptest` where needed. Add or update `*_test.go` files next to changed Go packages. There is no frontend unit-test runner configured; use `npm run check:ui` and manually exercise affected UI flows. - -## Commit & Pull Request Guidelines - -Recent history uses short imperative commits with prefixes such as `fix:` and `feat:`. Pull requests should include a concise description, linked issue when available, test results, and screenshots or recordings for visible UI changes. Note new environment variables or nginx routing updates. - -## Security & Configuration Tips - -Do not commit secrets. Auth depends on `AUTH_USERNAME`, `AUTH_PASSWORD`, and `AUTH_SECRET`; tests should set these with `t.Setenv`. - -The backend repository's protobuf definitions are the only API source of truth. The frontend tracks the generated `agentcompose/v2` and `health/v1` TypeScript clients directly in this repository so backend and UI changes can be reviewed together across repositories. Regenerate them with the documented generator; never edit generated protobuf or Connect client files by hand. Do not reintroduce the published `@chaitin-ai/agent-compose-client` package. diff --git a/README.md b/README.md index 6efa30a..7163a2c 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,163 @@ -# agent-compose-ui +# Agent Compose Web -Web UI for [agent-compose](https://github.com/chaitin/agent-compose) — a Svelte + Vite single-page app that talks to the agent-compose daemon over ConnectRPC. +Agent Compose Web 是 Agent Compose 的浏览器管理界面,用于编辑和启用智能体应用、观察运行状态,并管理运行时与系统资源。前端直接使用 Agent Compose V2 Connect RPC,与本地脚本服务配合保存 YAML 中引用的脚本文件。 -The backend repository's protobuf definitions are the API source of truth. This -repository directly tracks the generated `agentcompose/v2` and `health/v1` -TypeScript clients under `src/gen/`. +## 主要功能 -## Develop +- 使用 Monaco Editor 编辑、校验和启用 Agent Compose YAML。 +- 在浏览器中保存、切换和删除多个未启用草稿。 +- 预览项目变更,并在启用前执行镜像与依赖检查。 +- 查看智能体、Scheduler、Run、Sandbox 及其事件和日志。 +- 手动运行智能体,跟踪批量运行和最近一次运行结果。 +- 暂停项目运行活动,停止运行,并软删除智能体应用。 +- 管理镜像、环境变量、数据卷、缓存和能力服务配置。 +- 管理 YAML 中的内联脚本与 `$ref` 脚本文件。 -Requires a running agent-compose daemon on `http://127.0.0.1:7410` — the Vite -dev server proxies RPC/API/Jupyter calls to it. +## 技术栈 + +| 类别 | 技术 | +| --- | --- | +| UI | Svelte 5、TypeScript | +| 构建工具 | Vite 7、Bun | +| 编辑器 | Monaco Editor | +| 终端 | xterm.js | +| API | Connect RPC、Protobuf、`@bufbuild/protobuf` | +| YAML | js-yaml | +| 组件测试 | Vitest、Testing Library、happy-dom | + +## 快速开始 + +### 前置条件 + +- **Bun** ≥ 1.3:本地开发使用 Bun 作为运行时与包管理器(`dev` 脚本依赖 `bun run`)。安装见 。 +- **可访问的 Agent Compose daemon**,默认地址 `http://127.0.0.1:7410`。前端所有 RPC 都会代理到该地址,daemon 未启动时页面可打开但数据为空。 +- 脚本服务默认监听 `http://127.0.0.1:7420`,由 `bun run dev` 一并拉起,无需单独配置。 +- 镜像、Sandbox 和 LLM 相关功能依赖 daemon 侧对应运行时与模型配置。 + +> 仅做生产构建或用 Docker 启动时,可用 `npm` 替代 Bun(见下文对应章节)。 + +### 方式一:本地开发(推荐) + +```bash +# 1. 安装依赖 +bun install + +# 2. 启动前端 + 脚本服务(一条命令同时拉起两者) +bun run dev +``` + +启动后打开 。 + +`bun run dev` 会运行 `scripts/dev.mjs`,它自动生成一个随机 `SCRIPT_SERVICE_TOKEN` 并共享给前端代理与脚本服务,因此本地开发**无需**手动配置任何环境变量。两个子进程任一退出时,另一个会被一并终止。 + +如需分别启动(例如只调试前端): + +```bash +bun run dev:web # 仅前端,Vite 开发服务器,监听 0.0.0.0:5174 +bun run dev:scripts # 仅脚本服务,监听 127.0.0.1:7420 +``` + +### 方式二:Docker 启动 + +`docker/` 目录提供两个 compose 文件,均可通过 `docker compose up --build` 一键构建并运行,详见 [`docker/README.md`](docker/README.md): + +| 文件 | 包含服务 | 适用场景 | +| --- | --- | --- | +| `docker-compose.yml` | web + script-service | 纯前端,连接**外部**已运行的 agent-compose | +| `docker-compose.full.yml` | web + script-service + agent-compose 后端 | 前后端一体,单栈拉起 | ```bash -npm ci -npm run dev:ui # listens on 0.0.0.0:5174 +cd docker +cp .env.example .env # 必须设置 SCRIPT_SERVICE_TOKEN(示例留空,未设置则启动失败):openssl rand -hex 32 + +# 纯前端(连接宿主或其他已运行的 agent-compose) +docker compose up --build +# 或前后端一体(在栈内拉起 agent-compose 后端) +docker compose -f docker-compose.full.yml up --build ``` -Open `http://:5174/` from another machine. The development server proxies -v2 ConnectRPC, health, REST API, and Jupyter requests to the local daemon. -Set `AGENT_COMPOSE_DEV_BACKEND` to use a daemon URL other than -`http://127.0.0.1:7410`. +启动后打开 (端口可在 `.env` 的 `WEB_PORT` 调整)。Docker 构建使用 `node:22-alpine` + `npm install`(不用 Bun,原因见 docker/README.md)。 + +### 方式三:生产构建 + +```bash +bun install # 或 npm install +bun run build # 或 npm run build,产物输出到 dist/ +``` + +`dist/` 为纯静态文件,可由任意静态服务器托管。注意:生产部署需像开发期一样把以下路径**反向代理**到 agent-compose daemon 与脚本服务,否则 API 不可达: + +- `/agentcompose.v1.*`、`/agentcompose.v2.*`、`/health.v1.*`、`/api/*` → agent-compose daemon `127.0.0.1:7410` +- `/script-api/*` → 脚本服务 `127.0.0.1:7420`(需注入共享的 `x-script-service-token` 请求头) + +参考实现见 `docker/nginx/default.conf.template`(nginx envsubst 模板)。 + +## 常用脚本 + +| 命令 | 作用 | +| --- | --- | +| `bun run dev` | 本地开发:同时启动前端与脚本服务 | +| `bun run dev:web` | 仅启动前端开发服务器 | +| `bun run dev:scripts` | 仅启动脚本服务 | +| `bun run build` | 生产构建,产物在 `dist/` | +| `bun run check` | `svelte-check` 类型检查 | +| `bun run gen` | 用 Buf 重新生成 Protobuf 客户端到 `src/gen` | +| `bun run test` | 运行 `.test.js` / `.test.mjs` 单元测试 | +| `bun run test:component` | 运行 Vitest 组件测试 | +| `bun run test:all` | check + 单元测试 + e2e 助手测试 + 组件测试 | +| `bun run test:e2e:real` | 端到端真实数据测试(需运行中的 daemon + 前端 + Playwright) | + +## 环境与代理配置 + +Vite 开发服务器默认使用端口 `5174`,并将浏览器请求代理到后端服务: + +| 请求路径 | 目标服务 | +| --- | --- | +| `/agentcompose.v1.*` | Agent Compose daemon `127.0.0.1:7410` | +| `/agentcompose.v2.*` | Agent Compose daemon `127.0.0.1:7410` | +| `/health.v1.*` | Agent Compose daemon `127.0.0.1:7410` | +| `/api/*` | Agent Compose daemon `127.0.0.1:7410` | +| `/script-api/*` | 脚本服务 `127.0.0.1:7420` | + +`SCRIPT_SERVICE_TOKEN` 用于前端代理与脚本服务之间的内部认证。配置后,Vite 会把它作为 `x-script-service-token` 请求头转发给脚本服务。前端代理与脚本服务必须共享同一个令牌;本地 `bun run dev` 会自动生成并共享,Docker 模式由 `.env` 统一注入。`docker/.env.example` 不再内置任何 token(留空),脚本服务启动时会拒绝空值、过短或已知占位符令牌,需用 `openssl rand -hex 32` 自行生成。 + +与 Agent、模型、镜像、环境变量和能力服务有关的配置由 Agent Compose daemon 管理,不在前端仓库中保存密钥。前端假定 daemon 控制面认证**关闭**(`AGENT_COMPOSE_AUTH_TOKEN` 为空);若你在后端启用了该 token,需让反代额外注入 `Authorization: Bearer`(当前模板与 dev 行为一致,未注入)。 + +## 目录结构 + +```text +agent-compose-web/ +├── README.md # 项目主文档 +├── docker/ # Docker 构建与编排(纯前端 / 前后端一体),见 docker/README.md +├── script-service/ # 本地脚本文件与 manifest 服务 +├── scripts/ # 开发编排脚本(dev.mjs 等) +├── src/ +│ ├── components/ # 通用 UI、Toolbar、Sidebar 和脚本组件 +│ ├── gen/ # Protobuf 生成的 TypeScript 客户端 +│ ├── lib/ # Store、RPC、YAML 和领域逻辑 +│ ├── modals/ # 操作弹框 +│ ├── pages/ # 系统管理页面 +│ └── views/runtime/ # Agent、Run、Scheduler、Sandbox 运行视图 +├── test/ # 跨组件与页面测试 +├── e2e/ # 端到端测试与真实数据夹具 +├── proto/ # Protobuf 协议定义(供 buf 生成客户端) +├── package.json +└── vite.config.ts +``` + +`src/gen` 属于生成代码目录,不应直接手工修改。 + +## Protobuf 客户端生成 -## Build +前端协议客户端由 Buf 根据 Agent Compose Protobuf 定义生成,配置入口位于 `buf.gen.yaml`。需要在协议定义更新后重新生成客户端时,在仓库根目录执行: ```bash -npm run build:ui # outputs to dist/ -go test ./... -docker build -f nginx/Dockerfile -t agent-compose-ui:local . +bun run gen ``` -Set `AGENT_COMPOSE_BASE` to host the app under a sub-path (default `/`). +生成结果写入 `src/gen`。应提交协议源或生成配置对应的变更,并保持生成文件与后端协议版本一致。 -## Deploy +## 测试 -`nginx/Dockerfile` builds the Svelte UI and the Go UI server, then packages both -into the nginx-based runtime image. nginx serves static assets and forwards -API/RPC/OAuth/Jupyter routes to the local Go UI server, which handles browser -auth/OAuth and proxies the daemon. CI publishes the image to -`ghcr.io/chaitin/agent-compose-ui`. +- **单元 / 组件测试**:`bun run test`(`.test.js`/`.test.mjs`)与 `bun run test:component`(Vitest + Testing Library),无需外部服务,可接入 CI。 +- **端到端测试**(`e2e/`):针对**真实** agent-compose daemon 与前端运行,需先启动 daemon(`7410`)与前端(`5174`),并安装 Playwright 浏览器(`npx playwright install`)。通过 `bun run test:e2e:real` 触发,报告写入 `e2e/reports/`。属于可选的开发者测试,不在默认 `bun run test` 流程中。 diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..8cc7d41 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,12 @@ +version: v2 +plugins: + - local: protoc-gen-es + out: src/gen + opt: + - target=ts + - import_extension=.js + - local: protoc-gen-connect-es + out: src/gen + opt: + - target=ts + - import_extension=.js diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..ba5ddf5 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: proto diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..31a3b1c --- /dev/null +++ b/bun.lock @@ -0,0 +1,439 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "agent-compose-console", + "dependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@connectrpc/connect": "^1.7.0", + "@connectrpc/connect-web": "^1.7.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "js-yaml": "^4.1.0", + "monaco-editor": "^0.52.0", + }, + "devDependencies": { + "@bufbuild/buf": "^1.50.0", + "@bufbuild/protoc-gen-es": "^1.10.0", + "@connectrpc/protoc-gen-connect-es": "^1.7.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.4.2", + "@tsconfig/svelte": "^5.0.0", + "@types/js-yaml": "^4.0.9", + "@vitest/coverage-v8": "^4.1.10", + "happy-dom": "^20.10.6", + "playwright": "^1.61.1", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^7.0.0", + "vite-plugin-monaco-editor": "^1.1.0", + "vitest": "^4.1.10", + }, + }, + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "https://npm.in.chaitin.net/@adobe/css-tools/-/css-tools-4.5.0.tgz", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "https://npm.in.chaitin.net/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://npm.in.chaitin.net/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://npm.in.chaitin.net/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "https://npm.in.chaitin.net/@babel/parser/-/parser-7.29.7.tgz", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "https://npm.in.chaitin.net/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/types": ["@babel/types@7.29.7", "https://npm.in.chaitin.net/@babel/types/-/types-7.29.7.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "https://npm.in.chaitin.net/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + + "@bufbuild/buf": ["@bufbuild/buf@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf/-/buf-1.71.0.tgz", { "optionalDependencies": { "@bufbuild/buf-darwin-arm64": "1.71.0", "@bufbuild/buf-darwin-x64": "1.71.0", "@bufbuild/buf-linux-aarch64": "1.71.0", "@bufbuild/buf-linux-armv7": "1.71.0", "@bufbuild/buf-linux-x64": "1.71.0", "@bufbuild/buf-win32-arm64": "1.71.0", "@bufbuild/buf-win32-x64": "1.71.0" }, "bin": { "buf": "bin/buf", "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" } }, "sha512-GDcjBCwLgHT/4nX4YSnYatZ7sDZDpHV6dxQvoT2/P6gKvV23O6hl8NryzLIRKmeau0FRXpQKHVy1dMfnBSpy+w=="], + + "@bufbuild/buf-darwin-arm64": ["@bufbuild/buf-darwin-arm64@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.71.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-qZ7xZQyen/jOKFPVs3dlN9pMA56PI4YEo3r4/9ixtiH9gyFgfowR31axsocUgXGThjiN8mvOA8WfpG2tvaSvsw=="], + + "@bufbuild/buf-darwin-x64": ["@bufbuild/buf-darwin-x64@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.71.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-2w95pc3X+z06/J66i6uNzA8QPuVOpbPrwyb6tkK0AcJFNvKPVYr4BxVC2koyImrQ3rxY1n9q8qviWMjSvq9fOA=="], + + "@bufbuild/buf-linux-aarch64": ["@bufbuild/buf-linux-aarch64@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.71.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-dwxErryMI3MRwtP/IgfdrqEjiAmVpttGhmO3xihiJIV2EAXt9J5yjzHhEDvnSgQ6nmNjEvO5QczcIaQjZEwF6A=="], + + "@bufbuild/buf-linux-armv7": ["@bufbuild/buf-linux-armv7@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.71.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-pfc+Qexm5C59VeRUjVmEvxkCXT5QbMR1R/CUtcSlk+spOFVwna0bSpkqIsky3kkHfzxiNSOsz3iki9/pAVX+CA=="], + + "@bufbuild/buf-linux-x64": ["@bufbuild/buf-linux-x64@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.71.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Y7jLxr3wpMkpQSqZU/MrDmDSCkF4GxvhIL7wnNdSRpkhYAY6TPRHN+5nNgV7jp6mQ0zQSYh0MGxBeMgt/UVdmQ=="], + + "@bufbuild/buf-win32-arm64": ["@bufbuild/buf-win32-arm64@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.71.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-UrxtD99zLE1qImtQC/W3a9cuj0/kB53B1bK38kmCMRFow939FhdZtqTRjbnZWauRi/pzAsjDyPCvnTa2XKT8Cg=="], + + "@bufbuild/buf-win32-x64": ["@bufbuild/buf-win32-x64@1.71.0", "https://npm.in.chaitin.net/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.71.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-+npiOimJ7ggeLul3KFwSlOjZnAZYwt3el64dJ3nJQMnui0avyvsRmU02o1bZI5yUnBvhcnTWdEbfRXUnkkVtgQ=="], + + "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "https://npm.in.chaitin.net/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + + "@bufbuild/protoc-gen-es": ["@bufbuild/protoc-gen-es@1.10.1", "https://npm.in.chaitin.net/@bufbuild/protoc-gen-es/-/protoc-gen-es-1.10.1.tgz", { "dependencies": { "@bufbuild/protobuf": "^1.10.1", "@bufbuild/protoplugin": "1.10.1" }, "bin": { "protoc-gen-es": "bin/protoc-gen-es" } }, "sha512-YADugbvibIdZSb0NGf5OF87IyKTuMvMFZ7vMHgm6pL1SCfDwJ/ZRianTdrPG9hq/gOipK+NwHmXBViyS3J7nxA=="], + + "@bufbuild/protoplugin": ["@bufbuild/protoplugin@1.10.1", "https://npm.in.chaitin.net/@bufbuild/protoplugin/-/protoplugin-1.10.1.tgz", { "dependencies": { "@bufbuild/protobuf": "1.10.1", "@typescript/vfs": "^1.4.0", "typescript": "4.5.2" } }, "sha512-LaSbfwabAFIvbVnbn8jWwElRoffCIxhVraO8arliVwWupWezHLXgqPHEYLXZY/SsAR+/YsFBQJa8tAGtNPJyaQ=="], + + "@connectrpc/connect": ["@connectrpc/connect@1.7.0", "https://npm.in.chaitin.net/@connectrpc/connect/-/connect-1.7.0.tgz", { "peerDependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w=="], + + "@connectrpc/connect-web": ["@connectrpc/connect-web@1.7.0", "https://npm.in.chaitin.net/@connectrpc/connect-web/-/connect-web-1.7.0.tgz", { "peerDependencies": { "@bufbuild/protobuf": "^1.10.0", "@connectrpc/connect": "1.7.0" } }, "sha512-qyP0YOnUPRWwCc/VfsoydMJvkb7EyUPr2q9sHgBuJzbADjiqck1gKH5V5ZPzPhTLBvmz5UvG+wiZ5sMRQHU1MQ=="], + + "@connectrpc/protoc-gen-connect-es": ["@connectrpc/protoc-gen-connect-es@1.7.0", "https://npm.in.chaitin.net/@connectrpc/protoc-gen-connect-es/-/protoc-gen-connect-es-1.7.0.tgz", { "dependencies": { "@bufbuild/protobuf": "^1.10.0", "@bufbuild/protoplugin": "^1.10.0" }, "peerDependencies": { "@bufbuild/protoc-gen-es": "^1.10.0", "@connectrpc/connect": "1.7.0" }, "optionalPeers": ["@bufbuild/protoc-gen-es", "@connectrpc/connect"], "bin": { "protoc-gen-connect-es": "bin/protoc-gen-connect-es" } }, "sha512-g2rE799dxGgXtwSTBOJoSlzCy3HN0IX/Es8uKsCgXRmco8o277/bb5nz1X8TmvBooCBGNdtEdUDG50olcvS9jQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://npm.in.chaitin.net/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://npm.in.chaitin.net/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/android-x64/-/android-x64-0.28.1.tgz", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "https://npm.in.chaitin.net/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "https://npm.in.chaitin.net/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "https://npm.in.chaitin.net/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://npm.in.chaitin.net/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://npm.in.chaitin.net/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://npm.in.chaitin.net/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://npm.in.chaitin.net/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://npm.in.chaitin.net/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "https://npm.in.chaitin.net/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://npm.in.chaitin.net/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "https://npm.in.chaitin.net/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="], + + "@sveltejs/load-config": ["@sveltejs/load-config@0.2.0", "https://npm.in.chaitin.net/@sveltejs/load-config/-/load-config-0.2.0.tgz", {}, "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "https://npm.in.chaitin.net/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "https://npm.in.chaitin.net/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "https://npm.in.chaitin.net/@testing-library/dom/-/dom-10.4.1.tgz", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "https://npm.in.chaitin.net/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/svelte": ["@testing-library/svelte@5.4.2", "https://npm.in.chaitin.net/@testing-library/svelte/-/svelte-5.4.2.tgz", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.1.3" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ=="], + + "@testing-library/svelte-core": ["@testing-library/svelte-core@1.1.3", "https://npm.in.chaitin.net/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g=="], + + "@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "https://npm.in.chaitin.net/@tsconfig/svelte/-/svelte-5.0.8.tgz", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "https://npm.in.chaitin.net/@types/aria-query/-/aria-query-5.0.4.tgz", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/chai": ["@types/chai@5.2.3", "https://npm.in.chaitin.net/@types/chai/-/chai-5.2.3.tgz", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "https://npm.in.chaitin.net/@types/deep-eql/-/deep-eql-4.0.2.tgz", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "https://npm.in.chaitin.net/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/js-yaml": ["@types/js-yaml@4.0.9", "https://npm.in.chaitin.net/@types/js-yaml/-/js-yaml-4.0.9.tgz", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + + "@types/node": ["@types/node@26.1.1", "https://npm.in.chaitin.net/@types/node/-/node-26.1.1.tgz", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "https://npm.in.chaitin.net/@types/trusted-types/-/trusted-types-2.0.7.tgz", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "https://npm.in.chaitin.net/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "https://npm.in.chaitin.net/@types/ws/-/ws-8.18.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@typescript/vfs": ["@typescript/vfs@1.6.4", "https://npm.in.chaitin.net/@typescript/vfs/-/vfs-1.6.4.tgz", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "https://npm.in.chaitin.net/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="], + + "@vitest/expect": ["@vitest/expect@4.1.10", "https://npm.in.chaitin.net/@vitest/expect/-/expect-4.1.10.tgz", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "https://npm.in.chaitin.net/@vitest/mocker/-/mocker-4.1.10.tgz", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "https://npm.in.chaitin.net/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "https://npm.in.chaitin.net/@vitest/runner/-/runner-4.1.10.tgz", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "https://npm.in.chaitin.net/@vitest/snapshot/-/snapshot-4.1.10.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "https://npm.in.chaitin.net/@vitest/spy/-/spy-4.1.10.tgz", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "https://npm.in.chaitin.net/@vitest/utils/-/utils-4.1.10.tgz", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + + "@xterm/addon-fit": ["@xterm/addon-fit@0.10.0", "https://npm.in.chaitin.net/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.11.0", "https://npm.in.chaitin.net/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q=="], + + "@xterm/xterm": ["@xterm/xterm@5.5.0", "https://npm.in.chaitin.net/@xterm/xterm/-/xterm-5.5.0.tgz", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], + + "acorn": ["acorn@8.17.0", "https://npm.in.chaitin.net/acorn/-/acorn-8.17.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "ansi-regex": ["ansi-regex@5.0.1", "https://npm.in.chaitin.net/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "https://npm.in.chaitin.net/ansi-styles/-/ansi-styles-5.2.0.tgz", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "argparse": ["argparse@2.0.1", "https://npm.in.chaitin.net/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.1", "https://npm.in.chaitin.net/aria-query/-/aria-query-5.3.1.tgz", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "assertion-error": ["assertion-error@2.0.1", "https://npm.in.chaitin.net/assertion-error/-/assertion-error-2.0.1.tgz", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "https://npm.in.chaitin.net/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], + + "axobject-query": ["axobject-query@4.1.0", "https://npm.in.chaitin.net/axobject-query/-/axobject-query-4.1.0.tgz", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "buffer-image-size": ["buffer-image-size@0.6.4", "https://npm.in.chaitin.net/buffer-image-size/-/buffer-image-size-0.6.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + + "chai": ["chai@6.2.2", "https://npm.in.chaitin.net/chai/-/chai-6.2.2.tgz", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chokidar": ["chokidar@4.0.3", "https://npm.in.chaitin.net/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "https://npm.in.chaitin.net/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "https://npm.in.chaitin.net/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "css.escape": ["css.escape@1.5.1", "https://npm.in.chaitin.net/css.escape/-/css.escape-1.5.1.tgz", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "debug": ["debug@4.4.3", "https://npm.in.chaitin.net/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "https://npm.in.chaitin.net/deepmerge/-/deepmerge-4.3.1.tgz", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "dequal": ["dequal@2.0.3", "https://npm.in.chaitin.net/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devalue": ["devalue@5.8.1", "https://npm.in.chaitin.net/devalue/-/devalue-5.8.1.tgz", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "https://npm.in.chaitin.net/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "entities": ["entities@7.0.1", "https://npm.in.chaitin.net/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "es-module-lexer": ["es-module-lexer@2.3.1", "https://npm.in.chaitin.net/es-module-lexer/-/es-module-lexer-2.3.1.tgz", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + + "esbuild": ["esbuild@0.28.1", "https://npm.in.chaitin.net/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "esm-env": ["esm-env@1.2.2", "https://npm.in.chaitin.net/esm-env/-/esm-env-1.2.2.tgz", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.13", "https://npm.in.chaitin.net/esrap/-/esrap-2.2.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA=="], + + "estree-walker": ["estree-walker@3.0.3", "https://npm.in.chaitin.net/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "https://npm.in.chaitin.net/expect-type/-/expect-type-1.4.0.tgz", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fdir": ["fdir@6.5.0", "https://npm.in.chaitin.net/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.2", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "happy-dom": ["happy-dom@20.10.6", "https://npm.in.chaitin.net/happy-dom/-/happy-dom-20.10.6.tgz", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + + "has-flag": ["has-flag@4.0.0", "https://npm.in.chaitin.net/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "html-escaper": ["html-escaper@2.0.2", "https://npm.in.chaitin.net/html-escaper/-/html-escaper-2.0.2.tgz", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "indent-string": ["indent-string@4.0.0", "https://npm.in.chaitin.net/indent-string/-/indent-string-4.0.0.tgz", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-reference": ["is-reference@3.0.3", "https://npm.in.chaitin.net/is-reference/-/is-reference-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "https://npm.in.chaitin.net/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "https://npm.in.chaitin.net/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "https://npm.in.chaitin.net/istanbul-reports/-/istanbul-reports-3.2.0.tgz", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "js-tokens": ["js-tokens@10.0.0", "https://npm.in.chaitin.net/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + + "js-yaml": ["js-yaml@4.3.0", "https://npm.in.chaitin.net/js-yaml/-/js-yaml-4.3.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + + "kleur": ["kleur@4.1.5", "https://npm.in.chaitin.net/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "locate-character": ["locate-character@3.0.0", "https://npm.in.chaitin.net/locate-character/-/locate-character-3.0.0.tgz", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "lz-string": ["lz-string@1.5.0", "https://npm.in.chaitin.net/lz-string/-/lz-string-1.5.0.tgz", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "https://npm.in.chaitin.net/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.3", "https://npm.in.chaitin.net/magicast/-/magicast-0.5.3.tgz", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + + "make-dir": ["make-dir@4.0.0", "https://npm.in.chaitin.net/make-dir/-/make-dir-4.0.0.tgz", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "min-indent": ["min-indent@1.0.1", "https://npm.in.chaitin.net/min-indent/-/min-indent-1.0.1.tgz", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "monaco-editor": ["monaco-editor@0.52.2", "https://npm.in.chaitin.net/monaco-editor/-/monaco-editor-0.52.2.tgz", {}, "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ=="], + + "mri": ["mri@1.2.0", "https://npm.in.chaitin.net/mri/-/mri-1.2.0.tgz", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "ms": ["ms@2.1.3", "https://npm.in.chaitin.net/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.15", "https://npm.in.chaitin.net/nanoid/-/nanoid-3.3.15.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "obug": ["obug@2.1.3", "https://npm.in.chaitin.net/obug/-/obug-2.1.3.tgz", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + + "pathe": ["pathe@2.0.3", "https://npm.in.chaitin.net/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "https://npm.in.chaitin.net/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.5", "https://npm.in.chaitin.net/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "playwright": ["playwright@1.61.1", "https://registry.npmmirror.com/playwright/-/playwright-1.61.1.tgz", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.61.1.tgz", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + + "postcss": ["postcss@8.5.16", "https://npm.in.chaitin.net/postcss/-/postcss-8.5.16.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + + "pretty-format": ["pretty-format@27.5.1", "https://npm.in.chaitin.net/pretty-format/-/pretty-format-27.5.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "react-is": ["react-is@17.0.2", "https://npm.in.chaitin.net/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "readdirp": ["readdirp@4.1.2", "https://npm.in.chaitin.net/readdirp/-/readdirp-4.1.2.tgz", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "redent": ["redent@3.0.0", "https://npm.in.chaitin.net/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "rollup": ["rollup@4.62.2", "https://npm.in.chaitin.net/rollup/-/rollup-4.62.2.tgz", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "sade": ["sade@1.8.1", "https://npm.in.chaitin.net/sade/-/sade-1.8.1.tgz", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "semver": ["semver@7.8.5", "https://npm.in.chaitin.net/semver/-/semver-7.8.5.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "siginfo": ["siginfo@2.0.0", "https://npm.in.chaitin.net/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "https://npm.in.chaitin.net/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "https://npm.in.chaitin.net/stackback/-/stackback-0.0.2.tgz", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "https://npm.in.chaitin.net/std-env/-/std-env-4.2.0.tgz", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "strip-indent": ["strip-indent@3.0.0", "https://npm.in.chaitin.net/strip-indent/-/strip-indent-3.0.0.tgz", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "supports-color": ["supports-color@7.2.0", "https://npm.in.chaitin.net/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "svelte": ["svelte@5.56.4", "https://npm.in.chaitin.net/svelte/-/svelte-5.56.4.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw=="], + + "svelte-check": ["svelte-check@4.7.2", "https://npm.in.chaitin.net/svelte-check/-/svelte-check-4.7.2.tgz", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A=="], + + "tinybench": ["tinybench@2.9.0", "https://npm.in.chaitin.net/tinybench/-/tinybench-2.9.0.tgz", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.2.4", "https://npm.in.chaitin.net/tinyexec/-/tinyexec-1.2.4.tgz", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "https://npm.in.chaitin.net/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "https://npm.in.chaitin.net/tinyrainbow/-/tinyrainbow-3.1.0.tgz", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "typescript": ["typescript@5.9.3", "https://npm.in.chaitin.net/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "https://npm.in.chaitin.net/undici-types/-/undici-types-8.3.0.tgz", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "vite": ["vite@7.3.6", "https://npm.in.chaitin.net/vite/-/vite-7.3.6.tgz", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + + "vite-plugin-monaco-editor": ["vite-plugin-monaco-editor@1.1.0", "https://npm.in.chaitin.net/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz", { "peerDependencies": { "monaco-editor": ">=0.33.0" } }, "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww=="], + + "vitefu": ["vitefu@1.1.3", "https://npm.in.chaitin.net/vitefu/-/vitefu-1.1.3.tgz", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "vitest": ["vitest@4.1.10", "https://npm.in.chaitin.net/vitest/-/vitest-4.1.10.tgz", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "https://npm.in.chaitin.net/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "https://npm.in.chaitin.net/why-is-node-running/-/why-is-node-running-2.3.0.tgz", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "ws": ["ws@8.21.0", "https://npm.in.chaitin.net/ws/-/ws-8.21.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "zimmerframe": ["zimmerframe@1.1.4", "https://npm.in.chaitin.net/zimmerframe/-/zimmerframe-1.1.4.tgz", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "https://npm.in.chaitin.net/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@bufbuild/protoplugin/typescript": ["typescript@4.5.2", "https://npm.in.chaitin.net/typescript/-/typescript-4.5.2.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "https://npm.in.chaitin.net/aria-query/-/aria-query-5.3.0.tgz", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://npm.in.chaitin.net/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "rollup/fsevents": ["fsevents@2.3.3", "https://npm.in.chaitin.net/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite/fsevents": ["fsevents@2.3.3", "https://npm.in.chaitin.net/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..35912aa --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./scripts/svelte-test-preload.mjs"] diff --git a/cmd/agent-compose-ui-server/main.go b/cmd/agent-compose-ui-server/main.go deleted file mode 100644 index ea61d97..0000000 --- a/cmd/agent-compose-ui-server/main.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "log" - - "agent-compose-ui/internal/app" -) - -func main() { - if err := app.Run(); err != nil { - log.Fatal(err) - } -} diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..2f86ceb --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,42 @@ +# ============================================================================= +# Agent Compose Web - Docker environment +# Copy this file to .env and adjust: cp .env.example .env +# ============================================================================= + +# Host port exposed by the web UI. +WEB_PORT=8080 + +# Shared internal token between the web (nginx) proxy and the script-service. +# The browser never sees this token; nginx injects it as x-script-service-token. +# REQUIRED: intentionally left empty so `cp .env.example .env && docker compose up` +# fails fast until you set your own. The script-service also refuses weak/known +# placeholders at startup. Generate one with: openssl rand -hex 32 +SCRIPT_SERVICE_TOKEN= + +# ----------------------------------------------------------------------------- +# Connection to the agent-compose daemon (used by BOTH compose files) +# ----------------------------------------------------------------------------- +# Frontend-only (docker-compose.yml): defaults to http://host.docker.internal:7410 +# (an agent-compose exposing 7410 on the host). +# Full (docker-compose.full.yml): defaults to http://agent-compose:7410 +# (the in-stack agent-compose service). +# Leave commented to use each file's default, or set explicitly: +# AGENT_COMPOSE_URL=http://agent-compose:7410 + +# ----------------------------------------------------------------------------- +# Full-stack only (docker-compose.full.yml) - agent-compose backend service +# ----------------------------------------------------------------------------- +# Backend image. Defaults to the public release. +# AGENT_COMPOSE_IMAGE=ghcr.io/chaitin/agent-compose:latest + +# Backend data directory and config file. Self-contained defaults: ./data and +# ./agent-compose.env (relative to this docker/ dir). For the config, copy the +# example first: cp agent-compose.env.example agent-compose.env +# AGENT_COMPOSE_DATA_DIR=./data +# AGENT_COMPOSE_ENV_FILE=./agent-compose.env + +# Host port to expose the backend's 7410 on (optional; the web reaches it +# internally regardless). Set to an empty value to skip publishing. +# AGENT_COMPOSE_PORT=7410 + +# AGENT_COMPOSE_RUNTIME_BASE_URL=http://agent-compose:7410 diff --git a/docker/Dockerfile.scripts b/docker/Dockerfile.scripts new file mode 100644 index 0000000..bf54d8e --- /dev/null +++ b/docker/Dockerfile.scripts @@ -0,0 +1,14 @@ +# syntax=docker/dockerfile:1 +# Script file service - a tiny Bun app with zero npm dependencies (Node builtins +# only), so no install step is needed. +FROM oven/bun:1-alpine +WORKDIR /app +COPY script-service/*.mjs ./ + +# In a container the service must bind 0.0.0.0 so the web (nginx) container can +# reach it. dev usage overrides this back to 127.0.0.1 via SCRIPT_SERVICE_HOST. +ENV SCRIPT_SERVICE_HOST=0.0.0.0 \ + SCRIPT_SERVICE_PORT=7420 + +EXPOSE 7420 +CMD ["bun", "/app/server.mjs"] diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..9cfc2be --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 + +# ---- Stage 1: build the SPA with Node + npm + Vite ---- +# Uses npm (not bun) for the install because bun's bundled BoringSSL CA store +# cannot verify the registry cert in networks with TLS interception, whereas +# npm uses the system/Node OpenSSL trust store and works in those environments. +FROM node:22-alpine AS build +WORKDIR /build + +# Install dependencies first (cached layer). `package*.json` copies package.json +# and package-lock.json when present. `npm install` is lenient: it uses the +# lockfile if present and in sync, otherwise resolves from package.json. For +# reproducible builds, commit a synced package-lock.json and switch to `npm ci`. +COPY package*.json ./ +RUN npm install + +# Build the static bundle (outputs /build/dist). +COPY . ./ +RUN npm run build + +# ---- Stage 2: nginx serves the static bundle and reverse-proxies the API ---- +FROM nginx:1.27-alpine +LABEL org.opencontainers.image.title="agent-compose-web" +LABEL org.opencontainers.image.description="Agent Compose Web (SPA + reverse proxy)" + +COPY --from=build /build/dist /usr/share/nginx/html +# The nginx image auto-runs envsubst on /etc/nginx/templates/*.template at start, +# substituting ${AGENT_COMPOSE_URL}, ${SCRIPT_SERVICE_URL}, ${SCRIPT_SERVICE_TOKEN}. +COPY docker/nginx/default.conf.template /etc/nginx/templates/default.conf.template + +EXPOSE 80 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..6bd8f00 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,75 @@ +# Agent Compose Web — Docker 启动 + +提供两个 compose 文件,均可通过 `docker compose up --build` 一键构建并运行: + +| 文件 | 包含服务 | 适用场景 | +| --- | --- | --- | +| `docker-compose.yml` | web + script-service | 纯前端,连接**外部**已运行的 agent-compose | +| `docker-compose.full.yml` | web + script-service + agent-compose 后端 | 前后端一体,单栈拉起 | + +## 服务说明 + +- **web**:nginx 既托管 SPA 静态文件,又把 API 路径反代到后端。 + - `/agentcompose.v1./v2.`、`/health.v1.`、`/api/` → agent-compose daemon + - `/script-api/` → script-service(nginx 注入内部 token) + - 其余路径 → SPA(`try_files` 回退 `index.html`,支持 hash 路由与 `/events/` 真实路径) +- **scripts**:bun 运行的脚本文件服务(无外部依赖),脚本文件存于命名卷 `script-data`。 +- **agent-compose**(仅 full):后端 daemon,挂载宿主 `docker.sock`、后端 `data/` 与 `.env`。 + +## 前置准备 + +1. 已安装 Docker 与 Docker Compose。 +2. `cp .env.example .env`,并设置 `SCRIPT_SERVICE_TOKEN`(示例文件留空,未设置则 `docker compose up` 会直接报错退出):`openssl rand -hex 32`。 +3. (仅 full 模式)`cp agent-compose.env.example agent-compose.env`,按需编辑后端 daemon 配置;保持注释即用镜像默认值。 + +## 用法 + +### 纯前端(连接外部 agent-compose) + +```bash +cd docker +cp .env.example .env +docker compose up --build +# 打开 http://localhost:8080 +``` + +默认通过 `http://host.docker.internal:7410` 连接宿主上已暴露 7410 的 agent-compose。 +若 agent-compose 在另一容器内:把它接入 `agent-web-net` 网络并设 `AGENT_COMPOSE_URL=http://agent-compose:7410`,或直接指向其地址。 + +### 前后端一体 + +```bash +cd docker +cp .env.example .env +docker compose -f docker-compose.full.yml up --build +# 打开 http://localhost:8080 +``` + +后端默认使用镜像 `ghcr.io/chaitin/agent-compose:latest`,数据与配置默认自包含于 `docker/` 目录下(`./data` 与 `./agent-compose.env`),无需同级 `../../agent-compose` 仓库。首次使用先复制后端配置示例:`cp agent-compose.env.example agent-compose.env`(保持注释即用镜像默认值)。若需迁移位置,在 `.env` 中覆盖 `AGENT_COMPOSE_DATA_DIR` / `AGENT_COMPOSE_ENV_FILE` / `AGENT_COMPOSE_IMAGE`。 + +> 注意:full 模式会挂载宿主 `/var/run/docker.sock`(agent-compose 管理沙箱所需),并占用宿主 `7410` 端口(可用 `AGENT_COMPOSE_PORT` 调整)。 +> **不要与宿主机已运行的 agent-compose 并存**:full 模式用 Docker 容器跑 agent-compose,需独占 `7410` 端口与 `data/` 目录。若宿主机已在跑 agent-compose(占用 7410 或持有 `data/` 锁),full 模式会因端口/数据冲突启动失败。此时请先停掉宿主机的 agent-compose,或改用上面的**纯前端模式**连接宿主机 daemon。 + +## 连接与认证 + +- web 与开发期行为一致:假定 agent-compose daemon 的控制面认证**关闭**(`AGENT_COMPOSE_AUTH_TOKEN` 为空)。若你在后端 `.env` 中启用了该 token,需另行让反代注入 `Authorization: Bearer`(当前模板未注入,与 dev 一致)。 +- `SCRIPT_SERVICE_TOKEN` 是 web 与 script-service 之间的内部共享令牌,不会下发到浏览器。 + +## 端口 + +| 端口 | 服务 | 默认 | +| --- | --- | --- | +| `WEB_PORT` | web UI | 8080 → 80 | +| `AGENT_COMPOSE_PORT`(仅 full) | agent-compose | 7410 → 7410 | + +## 构建说明 + +- web 镜像为多阶段构建:`node:22-alpine` 执行 `npm install` + `npm run build`(即 `vite build`),产物拷入 `nginx:1.27-alpine`。用 npm 而非 bun 安装依赖,是因为 bun 自带的 BoringSSL CA 在有 TLS 拦截的网络里无法校验 registry 证书,而 npm 用系统/Node 的 OpenSSL 信任库可正常工作。 +- scripts 镜像基于 `oven/bun:1-alpine`,无 `install` 步骤(script-service 仅用 Node 内置模块),不受上述问题影响。 +- web 构建用 `npm install`(容忍 `package-lock.json` 与 `package.json` 的轻微不同步);如需可复现构建,提交同步后的 `package-lock.json` 后可改为 `npm ci`。 + +## 常见问题 + +- **web 打开但数据为空/报错**:检查 agent-compose 是否可达。纯前端模式下确认 `AGENT_COMPOSE_URL` 指向正确地址且 daemon 已启动;full 模式下查看 `agent-compose` 容器日志。 +- **端口冲突**:调整 `.env` 中的 `WEB_PORT` 或 `AGENT_COMPOSE_PORT`。 +- **脚本引用功能不可用**:确认 `scripts` 容器正常运行,且 `SCRIPT_SERVICE_TOKEN` 在 web 与 scripts 间一致(compose 已保证)。 diff --git a/docker/agent-compose.env.example b/docker/agent-compose.env.example new file mode 100644 index 0000000..7b727ed --- /dev/null +++ b/docker/agent-compose.env.example @@ -0,0 +1,28 @@ +# ============================================================================= +# Agent Compose backend (daemon) config - FULL-STACK compose only. +# ============================================================================= +# This is the agent-compose DAEMON's config file, separate from the web stack's +# .env (which holds SCRIPT_SERVICE_TOKEN, WEB_PORT, etc.). The full-stack +# compose mounts this file read-only into the agent-compose container at +# /data/work/.env (the daemon's working dir). +# +# Setup: +# cp agent-compose.env.example agent-compose.env +# +# Everything below is commented out, so the daemon starts with its image +# defaults and the stack is self-contained. Uncomment a line to override. +# The full set of keys lives in the agent-compose backend repo; these are the +# ones the web repo is aware of. +# ============================================================================= + +# Control-plane auth token. Empty/unset = auth OFF (this is the default the web +# UI and dev mode assume). Set a strong random value to require an +# Authorization: Bearer header on the daemon's control plane - and note that the +# bundled nginx template does NOT inject that header, so you'd need to add it. +# AGENT_COMPOSE_AUTH_TOKEN= + +# Where the daemon stores projects/runs/sandboxes inside the container. +# DATA_ROOT=/data + +# Runtime driver for sandboxes (e.g. docker). +# RUNTIME_DRIVER=docker diff --git a/docker/docker-compose.full.yml b/docker/docker-compose.full.yml new file mode 100644 index 0000000..17fb96c --- /dev/null +++ b/docker/docker-compose.full.yml @@ -0,0 +1,73 @@ +# Full stack: web (nginx SPA + reverse proxy) + script-service + agent-compose backend. +# The web container talks to agent-compose over the internal network. +# +# Usage: +# cp .env.example .env # set SCRIPT_SERVICE_TOKEN, etc. +# cp agent-compose.env.example agent-compose.env # backend daemon config (optional edits) +# docker compose -f docker-compose.full.yml up --build +# +# The agent-compose service mounts the host docker.sock (to manage sandboxes), +# the backend data dir, and the backend .env. Defaults are self-contained under +# this docker/ dir (./data and ./agent-compose.env), so the stack runs without a +# sibling agent-compose checkout. Override AGENT_COMPOSE_DATA_DIR / +# AGENT_COMPOSE_ENV_FILE in .env to relocate either. + +services: + web: + build: + context: .. + dockerfile: docker/Dockerfile.web + ports: + - "${WEB_PORT:-8080}:80" + environment: + AGENT_COMPOSE_URL: ${AGENT_COMPOSE_URL:-http://agent-compose:7410} + SCRIPT_SERVICE_URL: http://scripts:7420 + SCRIPT_SERVICE_TOKEN: ${SCRIPT_SERVICE_TOKEN:?set SCRIPT_SERVICE_TOKEN in .env} + depends_on: + - scripts + - agent-compose + restart: unless-stopped + networks: + - agent-web-net + + scripts: + build: + context: .. + dockerfile: docker/Dockerfile.scripts + environment: + SCRIPT_SERVICE_TOKEN: ${SCRIPT_SERVICE_TOKEN:?set SCRIPT_SERVICE_TOKEN in .env} + SCRIPT_SERVICE_PORT: "7420" + SCRIPT_DATA_DIR: /data/scripts + volumes: + - script-data:/data/scripts + restart: unless-stopped + networks: + - agent-web-net + + agent-compose: + image: ${AGENT_COMPOSE_IMAGE:-ghcr.io/chaitin/agent-compose:latest} + restart: always + environment: + AGENT_COMPOSE_RUNTIME_BASE_URL: ${AGENT_COMPOSE_RUNTIME_BASE_URL:-http://agent-compose:7410} + volumes: + # Required: agent-compose manages sandboxes/containers via the host Docker daemon. + - /var/run/docker.sock:/var/run/docker.sock + # Backend data (projects, runs, sandboxes). Self-contained default: ./data + # (relative to this docker/ dir). Override with AGENT_COMPOSE_DATA_DIR. + - ${AGENT_COMPOSE_DATA_DIR:-./data}:/data + # Backend config (AUTH_*, DATA_ROOT, RUNTIME_DRIVER, ...). Mounted read-only. + # Self-contained default: ./agent-compose.env (copy from agent-compose.env.example). + # Override with AGENT_COMPOSE_ENV_FILE. + - ${AGENT_COMPOSE_ENV_FILE:-./agent-compose.env}:/data/work/.env:ro + working_dir: /data/work + ports: + - "${AGENT_COMPOSE_PORT:-7410}:7410" + networks: + - agent-web-net + +networks: + agent-web-net: + name: agent-web-net + +volumes: + script-data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..05c2446 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,53 @@ +# Frontend-only stack: web (nginx SPA + reverse proxy) + script-service. +# Connects to an EXTERNAL agent-compose daemon (default: the host's 7410). +# +# Usage: +# cp .env.example .env # then edit .env (set SCRIPT_SERVICE_TOKEN, etc.) +# docker compose up --build +# +# If your agent-compose runs as a Docker container instead of on the host, +# either set AGENT_COMPOSE_URL to its address, or attach it to the agent-web-net +# network and set AGENT_COMPOSE_URL=http://agent-compose:7410. +# To run agent-compose inside this stack too, use docker-compose.full.yml. + +services: + web: + build: + context: .. + dockerfile: docker/Dockerfile.web + ports: + - "${WEB_PORT:-8080}:80" + environment: + AGENT_COMPOSE_URL: ${AGENT_COMPOSE_URL:-http://host.docker.internal:7410} + SCRIPT_SERVICE_URL: http://scripts:7420 + SCRIPT_SERVICE_TOKEN: ${SCRIPT_SERVICE_TOKEN:?set SCRIPT_SERVICE_TOKEN in .env} + extra_hosts: + # Lets the container reach a host-exposed agent-compose via host.docker.internal. + - "host.docker.internal:host-gateway" + depends_on: + - scripts + restart: unless-stopped + networks: + - agent-web-net + + scripts: + build: + context: .. + dockerfile: docker/Dockerfile.scripts + environment: + SCRIPT_SERVICE_TOKEN: ${SCRIPT_SERVICE_TOKEN:?set SCRIPT_SERVICE_TOKEN in .env} + SCRIPT_SERVICE_PORT: "7420" + SCRIPT_DATA_DIR: /data/scripts + # SCRIPT_SERVICE_HOST=0.0.0.0 is baked into the image; override here if needed. + volumes: + - script-data:/data/scripts + restart: unless-stopped + networks: + - agent-web-net + +networks: + agent-web-net: + name: agent-web-net + +volumes: + script-data: diff --git a/docker/nginx/default.conf.template b/docker/nginx/default.conf.template new file mode 100644 index 0000000..c083c9a --- /dev/null +++ b/docker/nginx/default.conf.template @@ -0,0 +1,54 @@ +# nginx server config template. The official nginx image runs envsubst on this +# file at container start, substituting only the env vars present in the +# container (AGENT_COMPOSE_URL, SCRIPT_SERVICE_URL, SCRIPT_SERVICE_TOKEN). +# nginx builtin variables ($host, $uri, $remote_addr, $upstream_*) are NOT in +# the container env and are therefore left untouched. + +server { + listen 80; + server_name _; + client_max_body_size 10m; + + root /usr/share/nginx/html; + index index.html; + + # Docker embedded DNS - lets nginx resolve upstream service names (agent-compose, + # scripts) at request time so it tolerates backends starting after the web. + resolver 127.0.0.11 valid=10s ipv6=off; + + gzip on; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types text/plain text/css application/javascript application/json application/wasm image/svg+xml; + + # SPA fallback: serve static assets, fall back to index.html for client routes + # (hash routing plus the /events/ standalone real paths). + location / { + try_files $uri $uri/ /index.html; + } + + # Agent Compose daemon: Connect RPC (agentcompose.v1./v2.), health, REST API. + # rpc.ts uses window.location.origin as the RPC base URL, so these paths must + # be reverse-proxied to the daemon. + location ~ ^/(agentcompose\.v[12]\.|health\.v1\.|api/) { + set $upstream_ac ${AGENT_COMPOSE_URL}; + proxy_pass $upstream_ac; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # Script file service. The browser calls /script-api/* without a token; nginx + # injects the shared internal token header consumed by the script service. + location /script-api/ { + set $upstream_ss ${SCRIPT_SERVICE_URL}; + proxy_pass $upstream_ss; + proxy_set_header Host $host; + proxy_set_header X-Script-Service-Token ${SCRIPT_SERVICE_TOKEN}; + proxy_http_version 1.1; + proxy_read_timeout 60s; + } +} diff --git a/e2e/fixtures/e2e-yml10-20260716t014615207z/01-case.yml b/e2e/fixtures/e2e-yml10-20260716t014615207z/01-case.yml new file mode 100644 index 0000000..78b42e1 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t014615207z/01-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t014615207z-01 +variables: + CASE_KIND: { value: basic-command } +agents: + worker-1: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "1" } + YML_MARKER: { value: e2e-yml10-20260716t014615207z-01-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t014710785z/01-case.yml b/e2e/fixtures/e2e-yml10-20260716t014710785z/01-case.yml new file mode 100644 index 0000000..fd83024 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t014710785z/01-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t014710785z-01 +variables: + CASE_KIND: { value: basic-command } +agents: + worker-1: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "1" } + YML_MARKER: { value: e2e-yml10-20260716t014710785z-01-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t014932476z/01-case.yml b/e2e/fixtures/e2e-yml10-20260716t014932476z/01-case.yml new file mode 100644 index 0000000..902e31a --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t014932476z/01-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t014932476z-01 +variables: + CASE_KIND: { value: basic-command } +agents: + worker-1: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "1" } + YML_MARKER: { value: e2e-yml10-20260716t014932476z-01-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015004818z/01-case.yml b/e2e/fixtures/e2e-yml10-20260716t015004818z/01-case.yml new file mode 100644 index 0000000..3eb1a9d --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015004818z/01-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015004818z-01 +variables: + CASE_KIND: { value: basic-command } +agents: + worker-1: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "1" } + YML_MARKER: { value: e2e-yml10-20260716t015004818z-01-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015004818z/02-case.yml b/e2e/fixtures/e2e-yml10-20260716t015004818z/02-case.yml new file mode 100644 index 0000000..183d5dc --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015004818z/02-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015004818z-02 +variables: + CASE_KIND: { value: environment } +agents: + worker-2: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "2" } + YML_MARKER: { value: e2e-yml10-20260716t015004818z-02-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015004818z/03-case.yml b/e2e/fixtures/e2e-yml10-20260716t015004818z/03-case.yml new file mode 100644 index 0000000..880884a --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015004818z/03-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015004818z-03 +variables: + CASE_KIND: { value: unicode } +agents: + worker-3: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "3" } + YML_MARKER: { value: e2e-yml10-20260716t015004818z-03-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015004818z/04-case.yml b/e2e/fixtures/e2e-yml10-20260716t015004818z/04-case.yml new file mode 100644 index 0000000..8605094 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015004818z/04-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015004818z-04 +variables: + CASE_KIND: { value: failure-record } +agents: + worker-4: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "4" } + YML_MARKER: { value: e2e-yml10-20260716t015004818z-04-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015050955z/01-case.yml b/e2e/fixtures/e2e-yml10-20260716t015050955z/01-case.yml new file mode 100644 index 0000000..085d550 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015050955z/01-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015050955z-01 +variables: + CASE_KIND: { value: basic-command } +agents: + worker-1: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "1" } + YML_MARKER: { value: e2e-yml10-20260716t015050955z-01-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015225860z/01-case.yml b/e2e/fixtures/e2e-yml10-20260716t015225860z/01-case.yml new file mode 100644 index 0000000..d4ea434 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015225860z/01-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015225860z-01 +variables: + CASE_KIND: { value: basic-command } +agents: + worker-1: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "1" } + YML_MARKER: { value: e2e-yml10-20260716t015225860z-01-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/01-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/01-case.yml new file mode 100644 index 0000000..e9674b1 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/01-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-01 +variables: + CASE_KIND: { value: basic-command } +agents: + worker-1: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "1" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-01-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/02-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/02-case.yml new file mode 100644 index 0000000..88a7866 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/02-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-02 +variables: + CASE_KIND: { value: environment } +agents: + worker-2: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "2" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-02-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/03-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/03-case.yml new file mode 100644 index 0000000..d3838f0 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/03-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-03 +variables: + CASE_KIND: { value: unicode } +agents: + worker-3: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "3" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-03-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/04-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/04-case.yml new file mode 100644 index 0000000..ccd14b1 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/04-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-04 +variables: + CASE_KIND: { value: failure-record } +agents: + worker-4: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "4" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-04-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/05-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/05-case.yml new file mode 100644 index 0000000..d32e6a6 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/05-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-05 +network: + mode: default +agents: + worker-5: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "5" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-05-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/06-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/06-case.yml new file mode 100644 index 0000000..2e6989a --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/06-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-06 +variables: + CASE_KIND: { value: pipeline } +agents: + worker-6: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "6" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-06-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/07-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/07-case.yml new file mode 100644 index 0000000..be63d06 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/07-case.yml @@ -0,0 +1,19 @@ +name: e2e-yml10-20260716t015349578z-07 +variables: + CASE_KIND: { value: scheduler } +agents: + worker-7: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "7" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-07-bash-ok } + scheduler: + enabled: true + sandbox_policy: sticky + triggers: + - name: daily-check + interval: 24h + prompt: e2e-yml10-20260716t015349578z-07-bash-ok diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/08-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/08-case.yml new file mode 100644 index 0000000..89044aa --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/08-case.yml @@ -0,0 +1,13 @@ +name: e2e-yml10-20260716t015349578z-08 +variables: + CASE_KIND: { value: system-prompt } +agents: + worker-8: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "8" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-08-bash-ok } + system_prompt: 真实数据验收:保留中文配置 diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/09-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/09-case.yml new file mode 100644 index 0000000..fcfc7c4 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/09-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-09 +variables: + CASE_KIND: { value: shell-expansion } +agents: + worker-9: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "9" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-09-bash-ok } diff --git a/e2e/fixtures/e2e-yml10-20260716t015349578z/10-case.yml b/e2e/fixtures/e2e-yml10-20260716t015349578z/10-case.yml new file mode 100644 index 0000000..3df9803 --- /dev/null +++ b/e2e/fixtures/e2e-yml10-20260716t015349578z/10-case.yml @@ -0,0 +1,12 @@ +name: e2e-yml10-20260716t015349578z-10 +variables: + CASE_KIND: { value: audit-trace } +agents: + worker-10: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + env: + YML_CASE: { value: "10" } + YML_MARKER: { value: e2e-yml10-20260716t015349578z-10-bash-ok } diff --git a/e2e/fixtures/full-yaml/agent-compose.expanded.yml b/e2e/fixtures/full-yaml/agent-compose.expanded.yml new file mode 100644 index 0000000..add4f71 --- /dev/null +++ b/e2e/fixtures/full-yaml/agent-compose.expanded.yml @@ -0,0 +1,79 @@ +name: e2e-yaml-full-20260715t232500z +variables: + TEST_SUITE: + value: full-yaml-e2e +network: + mode: default +workspaces: + fixture-workspace: + provider: local + path: workspace +agents: + build-workspace-agent: + provider: codex + image: agent-compose-e2e-full:20260715 + driver: + docker: {} + env: + E2E_VISIBLE: + value: visible-value + E2E_EMPTY: "" + workspace: + name: fixture-workspace + build: + context: /root/agent/agent-compose-web/web/e2e/fixtures/full-yaml/build-context + dockerfile: Dockerfile + target: runtime + args: + BUILD_MARKER: yaml-build-config-ok + platforms: [linux/amd64] + tags: [agent-compose-e2e-full:20260715] + no_cache: true + pull: false + prompt-agent: + provider: codex + model: svip/claude-opus-4-7 + system_prompt: Reply with exactly the marker requested by the user and no other text. + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + trigger-agent: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + scheduler: + enabled: true + sandbox_policy: sticky + triggers: + - name: interval-check + interval: 24h + prompt: e2e interval prompt + - name: cron-check + cron: "17 4 1 1 *" + prompt: e2e cron prompt + sandbox_policy: new + - name: timeout-check + timeout: 24h + prompt: e2e timeout prompt + - name: event-check + event: { topic: e2e.yaml.full.event } + prompt: e2e event prompt + script-agent: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + scheduler: + enabled: true + sandbox_policy: sticky + script: | + scheduler.interval("script-interval-check", function scriptIntervalCheck() { + scheduler.log("yaml scheduler script interval executed", { source: "full-yaml-e2e" }); + return scheduler.agent("Reply with exactly yaml-script-trigger-ok"); + }, 86400000); + + function main(payload) { + scheduler.log("yaml scheduler script main executed", payload); + return scheduler.agent("Reply with exactly yaml-script-main-ok"); + } diff --git a/e2e/fixtures/full-yaml/agent-compose.yml b/e2e/fixtures/full-yaml/agent-compose.yml new file mode 100644 index 0000000..3ad608e --- /dev/null +++ b/e2e/fixtures/full-yaml/agent-compose.yml @@ -0,0 +1,73 @@ +name: e2e-yaml-full-20260715t232500z +variables: + TEST_SUITE: + value: full-yaml-e2e +network: + mode: default +workspaces: + fixture-workspace: + provider: local + path: workspace +agents: + build-workspace-agent: + provider: codex + image: agent-compose-e2e-full:20260715 + driver: + docker: {} + env: + E2E_VISIBLE: + value: visible-value + E2E_EMPTY: "" + workspace: + name: fixture-workspace + build: + context: /root/agent/agent-compose-web/web/e2e/fixtures/full-yaml/build-context + dockerfile: Dockerfile + target: runtime + args: + BUILD_MARKER: yaml-build-config-ok + platforms: + - linux/amd64 + tags: + - agent-compose-e2e-full:20260715 + no_cache: true + pull: false + prompt-agent: + provider: codex + model: svip/claude-opus-4-7 + system_prompt: Reply with exactly the marker requested by the user and no other text. + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + trigger-agent: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + scheduler: + enabled: true + sandbox_policy: sticky + triggers: + - name: interval-check + interval: 24h + prompt: e2e interval prompt + - name: cron-check + cron: "17 4 1 1 *" + prompt: e2e cron prompt + sandbox_policy: new + - name: timeout-check + timeout: 24h + prompt: e2e timeout prompt + - name: event-check + event: + topic: e2e.yaml.full.event + prompt: e2e event prompt + script-agent: + provider: codex + image: ghcr.io/chaitin/agent-compose-guest:latest + driver: + docker: {} + scheduler: + enabled: true + sandbox_policy: sticky + script: $ref:e2e-yaml-full-20260715t232500z/scheduler.js diff --git a/e2e/fixtures/full-yaml/build-context/Dockerfile b/e2e/fixtures/full-yaml/build-context/Dockerfile new file mode 100644 index 0000000..0dc9808 --- /dev/null +++ b/e2e/fixtures/full-yaml/build-context/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/chaitin/agent-compose-guest:latest AS runtime +ARG BUILD_MARKER +RUN printf '%s' "$BUILD_MARKER" > /etc/agent-compose-yaml-build-marker diff --git a/e2e/fixtures/full-yaml/scheduler.js b/e2e/fixtures/full-yaml/scheduler.js new file mode 100644 index 0000000..593cfe6 --- /dev/null +++ b/e2e/fixtures/full-yaml/scheduler.js @@ -0,0 +1,9 @@ +scheduler.interval("script-interval-check", function scriptIntervalCheck() { + scheduler.log("yaml scheduler script interval executed", { source: "full-yaml-e2e" }); + return scheduler.agent("Reply with exactly yaml-script-trigger-ok"); +}, 86400000); + +function main(payload) { + scheduler.log("yaml scheduler script main executed", payload); + return scheduler.agent("Reply with exactly yaml-script-main-ok"); +} diff --git a/e2e/fixtures/full-yaml/workspace/workspace-marker.txt b/e2e/fixtures/full-yaml/workspace/workspace-marker.txt new file mode 100644 index 0000000..a26b06c --- /dev/null +++ b/e2e/fixtures/full-yaml/workspace/workspace-marker.txt @@ -0,0 +1 @@ +yaml-workspace-config-ok diff --git a/e2e/real-data/api.test.ts b/e2e/real-data/api.test.ts new file mode 100644 index 0000000..e24273e --- /dev/null +++ b/e2e/real-data/api.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { assertLedgerOwns, buildFixture, createLedger } from './fixtures'; +import { findTrackedSandbox, imageHasReference, imagePlatform, schedulerEventsRequest } from './api'; +import { Sandbox } from '../../src/gen/agentcompose/v2/agentcompose_pb'; + +describe('real-data fixture safety', () => { + test('builds deterministic exact markers and commands from a batch ID', () => { + const fixture = buildFixture('e2e-20260715-fixed'); + + expect(fixture.stdoutMarker).toBe('e2e-20260715-fixed-stdout'); + expect(fixture.stderrMarker).toBe('e2e-20260715-fixed-stderr'); + expect(fixture.llmMarker).toBe('e2e-20260715-fixed-llm-ok'); + expect(fixture.successCommand).toContain(fixture.stdoutMarker); + expect(fixture.failedCommand).toContain('exit 17'); + expect(fixture.scriptContent).toContain('e2e-20260715-fixed-script-ok'); + expect(fixture.agentImage).toBe('ghcr.io/chaitin/agent-compose-guest:latest'); + expect(fixture.agentImage).not.toBe(fixture.image); + }); + + test('ledger accepts only current-batch resources', () => { + const ledger = createLedger('e2e-fixed'); + ledger.projects.add('e2e-fixed-project'); + + expect(assertLedgerOwns(ledger, 'projects', 'e2e-fixed-project')).toBe('e2e-fixed-project'); + expect(() => assertLedgerOwns(ledger, 'projects', 'production')).toThrow('not owned'); + }); + + test('uses a unique safe batch format', () => { + const fixture = buildFixture(); + expect(fixture.batchId).toMatch(/^e2e-\d{8}t\d{6}z-[a-f0-9]{6}$/); + expect(fixture.projectName).toMatch(/^[a-z][a-z0-9_-]*$/); + }); + + test('reads image references and platform from the generated v2 shape', () => { + const image = { repoTags: ['busybox:1.36.1'], platform: { os: 'linux', architecture: 'amd64' } }; + expect(imageHasReference(image, 'busybox:1.36.1')).toBe(true); + expect(imagePlatform(image)).toBe('linux/amd64'); + }); + + test('walks sandbox cursors until a tracked sandbox is found', async () => { + const cursors: string[] = []; + const found = await findTrackedSandbox(async request => { + cursors.push(request.cursor); + return request.cursor + ? { sandboxes: [new Sandbox({ sandboxId: 'tracked' })], nextCursor: 'unused' } + : { sandboxes: [new Sandbox({ sandboxId: 'other' })], nextCursor: 'next' }; + }, new Set(['tracked'])); + + expect(found?.sandboxId).toBe('tracked'); + expect(cursors).toEqual(['', 'next']); + }); + + test('probes the complete direct v2 read-only surface', () => { + const source = readFileSync(new URL('./api.ts', import.meta.url), 'utf8'); + for (const call of [ + 'clients.sandbox.listSandboxes', + 'clients.sandbox.getSandbox', + 'clients.sandbox.listSandboxHistory', + 'clients.run.listSandboxRunEvents', + 'clients.dashboard.getDashboardOverview', + 'clients.project.listSchedulerEvents', + 'clients.settings.getGlobalEnv', + 'clients.capability.getCapabilityStatus', + ]) { + expect(source).toContain(call); + } + const schedulerRequest = schedulerEventsRequest('project-1', 'agent-1'); + expect(schedulerRequest.project?.projectId).toBe('project-1'); + expect(schedulerRequest.agentName).toBe('agent-1'); + expect(schedulerRequest.limit).toBe(100); + }); +}); diff --git a/e2e/real-data/api.ts b/e2e/real-data/api.ts new file mode 100644 index 0000000..c24b5fb --- /dev/null +++ b/e2e/real-data/api.ts @@ -0,0 +1,395 @@ +import { createClient, type Client } from '@connectrpc/connect'; +import { createConnectTransport } from '@connectrpc/connect-web'; +import { + CapabilityService, + DashboardService, + ImageService, + ProjectService, + RunService, + SandboxService, + SettingsService, +} from '../../src/gen/agentcompose/v2/agentcompose_connect'; +import { + AgentSpec, + ApplyProjectRequest, + DockerDriverSpec, + DriverSpec, + FollowRunLogsRequest, + GetCapabilityStatusRequest, + GetDashboardOverviewRequest, + GetGlobalEnvRequest, + GetProjectRequest, + GetRunRequest, + GetSandboxRequest, + GetSandboxStatsRequest, + ImageOperationStatus, + ImageStoreKind, + InspectImageRequest, + ListImagesRequest, + ListProjectsRequest, + ListRunsRequest, + ListSandboxHistoryRequest, + ListSandboxRunEventsRequest, + ListSandboxesRequest, + ListSchedulerEventsRequest, + ProjectRef, + ProjectSource, + ProjectSpec, + PullImageRequest, + RemoveImageRequest, + RemoveProjectRequest, + RemoveSandboxRequest, + RunAgentRequest, + RunSandboxCleanupPolicy, + RunSource, + RunStatus, + type Sandbox, + StopRunRequest, + StartRunRequest, + ValidateProjectRequest, +} from '../../src/gen/agentcompose/v2/agentcompose_pb'; +import type { Fixture, FixtureLedger } from './fixtures'; +import type { CaseRecorder, FieldAssertion } from './report'; + +export interface LiveClients { + project: Client; + run: Client; + image: Client; + sandbox: Client; + dashboard: Client; + settings: Client; + capability: Client; +} + +export interface RealDataContext { + daemonUrl: string; + frontendUrl: string; + fixture: Fixture; + ledger: FixtureLedger; + recorder: CaseRecorder; + clients: LiveClients; + projectId: string; + successfulRunId: string; + failedRunId: string; + stoppedRunId: string; +} + +export function createLiveClients(baseUrl: string): LiveClients { + const transport = createConnectTransport({ baseUrl }); + return { + project: createClient(ProjectService, transport), + run: createClient(RunService, transport), + image: createClient(ImageService, transport), + sandbox: createClient(SandboxService, transport), + dashboard: createClient(DashboardService, transport), + settings: createClient(SettingsService, transport), + capability: createClient(CapabilityService, transport), + }; +} + +export const exact = (field: string, expected: unknown, actual: unknown): FieldAssertion => ({ field, expected, actual, pass: Object.is(expected, actual) }); +export const predicate = (field: string, expected: string, actual: unknown, pass: boolean): FieldAssertion => ({ field, expected, actual, pass }); + +export function imageHasReference(image: { repoTags?: string[]; repoDigests?: string[] } | undefined, reference: string): boolean { + return [...(image?.repoTags ?? []), ...(image?.repoDigests ?? [])].includes(reference); +} + +export function imagePlatform(image: { platform?: { os?: string; architecture?: string } } | undefined): string { + return [image?.platform?.os, image?.platform?.architecture].filter(Boolean).join('/'); +} + +export function schedulerEventsRequest(projectId: string, agentName: string): ListSchedulerEventsRequest { + return new ListSchedulerEventsRequest({ + project: new ProjectRef({ projectId }), + agentName, + limit: 100, + }); +} + +export async function findTrackedSandbox( + fetchPage: (request: ListSandboxesRequest) => Promise<{ sandboxes: Sandbox[]; nextCursor: string }>, + trackedIds: ReadonlySet, +): Promise { + const seen = new Set(); + let cursor = ''; + while (true) { + const page = await fetchPage(new ListSandboxesRequest({ limit: 100, cursor })); + const tracked = page.sandboxes.find(sandbox => trackedIds.has(sandbox.sandboxId)); + if (tracked || !page.nextCursor) return tracked; + if (seen.has(page.nextCursor)) throw new Error(`Sandbox pagination returned repeated cursor: ${page.nextCursor}`); + seen.add(page.nextCursor); + cursor = page.nextCursor; + } +} + +function projectSpec(fixture: Fixture): ProjectSpec { + const docker = new DriverSpec({ name: 'docker', docker: new DockerDriverSpec() }); + return new ProjectSpec({ + name: fixture.projectName, + agents: [ + new AgentSpec({ name: 'deterministic-agent', provider: 'codex', image: fixture.agentImage, driver: docker }), + new AgentSpec({ name: 'llm-agent', provider: 'codex', image: fixture.agentImage, driver: docker, systemPrompt: 'Return exactly the marker requested by the user and no other text.' }), + ], + }); +} + +function source(fixture: Fixture): ProjectSource { + const root = `/tmp/${fixture.batchId}`; + return new ProjectSource({ composePath: `${root}/agent-compose.yml`, projectDir: root }); +} + +function runSummary(run: any) { + const summary = run?.summary; + return { + runId: summary?.runId ?? '', + projectId: summary?.projectId ?? '', + agentName: summary?.agentName ?? '', + sandboxId: summary?.sandboxId ?? '', + status: summary?.status ?? RunStatus.UNSPECIFIED, + exitCode: summary?.exitCode ?? 0, + output: run?.output ?? '', + error: summary?.error ?? '', + }; +} + +function trackRun(context: RealDataContext, run: any): ReturnType { + const value = runSummary(run); + if (value.runId) context.ledger.runs.add(value.runId); + if (value.sandboxId) context.ledger.sandboxes.add(value.sandboxId); + return value; +} + +export async function runApiCases(context: RealDataContext): Promise { + const { fixture, recorder, clients, ledger } = context; + await recorder.run('health', '服务健康', { url: `${context.daemonUrl}/api/version` }, { httpStatus: 200, err: null, version: 'non-empty' }, async () => { + const response = await fetch(`${context.daemonUrl}/api/version`); + const body = await response.json() as any; + return { actual: { httpStatus: response.status, err: body.err, version: body.data?.version, timestamp: body.data?.timestamp }, assertions: [exact('httpStatus', 200, response.status), exact('err', null, body.err), predicate('version', 'non-empty string', body.data?.version, typeof body.data?.version === 'string' && body.data.version.length > 0), predicate('timestamp', 'positive number', body.data?.timestamp, Number(body.data?.timestamp) > 0)] }; + }); + + const scriptBase = `${context.frontendUrl}/script-api/v1`; + await recorder.run('health', '脚本服务', { url: `${scriptBase}/health` }, { httpStatus: 200, ok: true }, async () => { + const response = await fetch(`${scriptBase}/health`); + const body = await response.json() as any; + return { actual: { httpStatus: response.status, ...body }, assertions: [exact('httpStatus', 200, response.status), exact('ok', true, body.ok)] }; + }); + + const initialImages = await clients.image.listImages(new ListImagesRequest({ query: fixture.image, all: true, limit: 100 })); + ledger.imageWasPresent = initialImages.images.some((image) => imageHasReference(image, fixture.image)); + await recorder.run('pull', '镜像', { imageRef: fixture.image }, { status: 'SUCCEEDED', imageListed: true }, async () => { + const pull = await clients.image.pullImage(new PullImageRequest({ imageRef: fixture.image, store: ImageStoreKind.DOCKER_DAEMON })); + ledger.imagePulled = !ledger.imageWasPresent && pull.status === ImageOperationStatus.SUCCEEDED; + const list = await clients.image.listImages(new ListImagesRequest({ query: 'busybox', all: true, limit: 100 })); + const found = list.images.find((image) => imageHasReference(image, fixture.image)); + return { actual: { status: ImageOperationStatus[pull.status], resolvedRef: pull.resolvedRef, imageListed: Boolean(found), imageId: found?.imageId }, assertions: [exact('status', 'SUCCEEDED', ImageOperationStatus[pull.status]), exact('imageListed', true, Boolean(found))] }; + }); + await recorder.run('inspect', '镜像', { imageRef: fixture.image }, { imageId: 'non-empty', sizeBytes: '>=0', platform: 'non-empty' }, async () => { + const response = await clients.image.inspectImage(new InspectImageRequest({ imageRef: fixture.image, store: ImageStoreKind.DOCKER_DAEMON, includeCacheStatus: true })); + const image = response.image; + const platform = imagePlatform(image); + return { actual: image?.toJson({ emitDefaultValues: true }), assertions: [predicate('imageId', 'non-empty', image?.imageId, Boolean(image?.imageId)), predicate('sizeBytes', '>=0', image?.sizeBytes?.toString(), BigInt(image?.sizeBytes?.toString() ?? '-1') >= 0n), predicate('platform', 'non-empty', platform, Boolean(platform))] }; + }); + + const spec = projectSpec(fixture); + const projectSource = source(fixture); + await recorder.run('valid', '项目校验', { name: spec.name, agents: spec.agents.map((agent) => agent.name) }, { valid: true, errorIssues: 0 }, async () => { + const response = await clients.project.validateProject(new ValidateProjectRequest({ spec, source: projectSource })); + return { actual: { valid: response.valid, issues: response.issues.map((issue) => issue.toJson()) }, assertions: [exact('valid', true, response.valid), exact('errorIssues', 0, response.issues.length)] }; + }); + await recorder.run('invalid', '项目校验', { name: '', agents: [] }, { rejected: true }, async () => { + try { + const response = await clients.project.validateProject(new ValidateProjectRequest({ spec: new ProjectSpec({ name: 'INVALID NAME' }), source: projectSource })); + return { actual: { valid: response.valid, issues: response.issues.map((issue) => issue.toJson()) }, assertions: [exact('rejected', true, !response.valid && response.issues.length > 0)] }; + } catch (error) { + return { actual: { rejected: true, error: String(error) }, assertions: [exact('rejected', true, true)] }; + } + }); + await recorder.run('apply', '项目', { name: fixture.projectName, agentCount: 2 }, { applied: true, projectId: 'non-empty', name: fixture.projectName, agentCount: 2 }, async () => { + const response = await clients.project.applyProject(new ApplyProjectRequest({ spec, source: projectSource })); + const summary = response.project?.summary; + context.projectId = summary?.projectId ?? ''; + if (context.projectId) ledger.projects.add(context.projectId); + return { actual: { applied: response.applied, projectId: context.projectId, name: summary?.name, agentCount: summary?.agentCount }, assertions: [exact('applied', true, response.applied), predicate('projectId', 'non-empty', context.projectId, Boolean(context.projectId)), exact('name', fixture.projectName, summary?.name), exact('agentCount', 2, summary?.agentCount)] }; + }); + await recorder.run('get', '项目', { projectId: context.projectId }, { name: fixture.projectName, agents: ['deterministic-agent', 'llm-agent'] }, async () => { + const response = await clients.project.getProject(new GetProjectRequest({ project: new ProjectRef({ projectId: context.projectId }), includeSpec: true })); + const names = response.project?.spec?.agents.map((agent) => agent.name) ?? []; + return { actual: { name: response.project?.summary?.name, agents: names, sourcePath: response.project?.summary?.sourcePath }, assertions: [exact('name', fixture.projectName, response.project?.summary?.name), exact('agents', 'deterministic-agent,llm-agent', names.join(','))] }; + }); + await recorder.run('list-filter-page', '项目', { query: fixture.batchId, offset: 0, limit: 1 }, { totalCount: 1, returned: 1, projectId: context.projectId }, async () => { + const response = await clients.project.listProjects(new ListProjectsRequest({ query: fixture.batchId, offset: 0, limit: 1 })); + return { actual: { totalCount: response.totalCount, returned: response.projects.length, projectIds: response.projects.map((project) => project.projectId) }, assertions: [exact('totalCount', 1, response.totalCount), exact('returned', 1, response.projects.length), exact('projectId', context.projectId, response.projects[0]?.projectId)] }; + }); + + await recorder.run('update', '项目', { projectId: context.projectId, systemPrompt: `${fixture.batchId}-updated` }, { applied: true, specHashChanged: true, systemPrompt: `${fixture.batchId}-updated` }, async () => { + const before = await clients.project.getProject(new GetProjectRequest({ project: new ProjectRef({ projectId: context.projectId }), includeSpec: true })); + const updatedSpec = before.project?.spec; + if (!updatedSpec) throw new Error('project spec missing before update'); + updatedSpec.agents[0].systemPrompt = `${fixture.batchId}-updated`; + const oldHash = before.project?.summary?.specHash ?? ''; + const applied = await clients.project.applyProject(new ApplyProjectRequest({ spec: updatedSpec, source: projectSource })); + const after = await clients.project.getProject(new GetProjectRequest({ project: new ProjectRef({ projectId: context.projectId }), includeSpec: true })); + return { actual: { applied: applied.applied, oldHash, newHash: after.project?.summary?.specHash, systemPrompt: after.project?.spec?.agents[0]?.systemPrompt }, assertions: [exact('applied', true, applied.applied), predicate('specHashChanged', 'newHash != oldHash', after.project?.summary?.specHash, Boolean(after.project?.summary?.specHash && after.project.summary.specHash !== oldHash)), exact('systemPrompt', `${fixture.batchId}-updated`, after.project?.spec?.agents[0]?.systemPrompt)] }; + }); + + await runScriptCases(context, scriptBase); + + await runCommandCase(context, 'success', fixture.successCommand, 0, fixture.stdoutMarker); + await runCommandCase(context, 'failure', fixture.failedCommand, 17, fixture.stderrMarker); + await recorder.run('list-filter-page', '运行', { projectId: context.projectId, agentName: 'deterministic-agent', limit: 20 }, { containsCreatedRuns: true, unrelatedExcluded: true }, async () => { + const response = await clients.run.listRuns(new ListRunsRequest({ projectId: context.projectId, agentName: 'deterministic-agent', limit: 20 })); + const ids = response.runs.map((run) => run.runId); + const created = [context.successfulRunId, context.failedRunId]; + return { actual: { ids, totalCount: response.totalCount }, assertions: [exact('containsCreatedRuns', true, created.every((id) => ids.includes(id))), exact('unrelatedExcluded', true, response.runs.every((run) => run.projectId === context.projectId && run.agentName === 'deterministic-agent'))] }; + }); + await recorder.run('history', '运行日志', { runId: context.successfulRunId, follow: false }, { contains: fixture.stdoutMarker, final: true }, async () => { + let data = ''; + let final = false; + for await (const chunk of clients.run.followRunLogs(new FollowRunLogsRequest({ projectId: context.projectId, runId: context.successfulRunId, tailLines: 100, follow: false }))) { data += chunk.data; final ||= chunk.isFinal; } + return { actual: { data, final }, assertions: [exact('contains', true, data.includes(fixture.stdoutMarker)), exact('final', true, final)] }; + }); + await recorder.run('stop', '运行', { command: fixture.stopCommand }, { stopRequested: true, terminal: 'not running' }, async () => { + const started = await clients.run.startRun(new StartRunRequest({ run: new RunAgentRequest({ projectId: context.projectId, agentName: 'deterministic-agent', command: fixture.stopCommand, source: RunSource.API, cleanupPolicy: RunSandboxCleanupPolicy.KEEP_RUNNING, clientRequestId: `${fixture.batchId}-stop` }) })); + context.stoppedRunId = started.run?.runId ?? ''; + if (context.stoppedRunId) ledger.runs.add(context.stoppedRunId); + const stop = await clients.run.stopRun(new StopRunRequest({ projectId: context.projectId, runId: context.stoppedRunId, reason: 'real-data E2E stop assertion' })); + let status = RunStatus.UNSPECIFIED; + for (let attempt = 0; attempt < 50; attempt++) { + const fetched = await clients.run.getRun(new GetRunRequest({ projectId: context.projectId, runId: context.stoppedRunId })); + status = fetched.run?.summary?.status ?? RunStatus.UNSPECIFIED; + if (fetched.run?.summary?.sandboxId) ledger.sandboxes.add(fetched.run.summary.sandboxId); + if (![RunStatus.PENDING, RunStatus.RUNNING, RunStatus.UNSPECIFIED].includes(status)) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return { actual: { runId: context.stoppedRunId, stopRequested: stop.stopRequested, status: RunStatus[status] }, assertions: [predicate('runId', 'non-empty', context.stoppedRunId, Boolean(context.stoppedRunId)), exact('stopRequested', true, stop.stopRequested), predicate('terminal', 'not PENDING/RUNNING', RunStatus[status], ![RunStatus.PENDING, RunStatus.RUNNING].includes(status))] }; + }); + + const sandboxId = [...ledger.sandboxes][0]; + if (sandboxId) { + await recorder.run('stats', '沙箱', { sandboxId }, { metrics: 'non-negative or unsupported' }, async () => { + try { + const response = await clients.sandbox.getSandboxStats(new GetSandboxStatsRequest({ sandboxId })); + return { actual: response.toJson({ emitDefaultValues: true }), assertions: [predicate('response', 'object', response.toJson(), Boolean(response))] }; + } catch (error) { + context.recorder.addUnavailable('stats-capability', '沙箱', { sandboxId }, { unavailableIsExplicit: true }, { error: String(error) }); + return { actual: { capabilityUnavailable: true }, assertions: [exact('capabilityUnavailable', true, true)] }; + } + }); + } + + await recorder.run('prompt', 'LLM Agent', { agentName: 'llm-agent', prompt: `Reply with exactly ${fixture.llmMarker}` }, { status: 'SUCCEEDED', containsMarker: true }, async () => { + const response = await clients.run.runAgent(new RunAgentRequest({ projectId: context.projectId, agentName: 'llm-agent', prompt: `Reply with exactly ${fixture.llmMarker}`, source: RunSource.API, cleanupPolicy: RunSandboxCleanupPolicy.KEEP_RUNNING, clientRequestId: `${fixture.batchId}-llm` })); + const run = trackRun(context, response.run); + return { actual: { ...run, output: run.output.slice(0, 1000) }, assertions: [exact('status', 'SUCCEEDED', RunStatus[run.status]), exact('containsMarker', true, run.output.includes(fixture.llmMarker))] }; + }); + + await recorder.run('list-detail', '沙箱', {}, { listed: true, detailMatches: true }, async () => { + const listedSandbox = await findTrackedSandbox(request => clients.sandbox.listSandboxes(request), ledger.sandboxes); + const detail = listedSandbox + ? await clients.sandbox.getSandbox(new GetSandboxRequest({ sandboxId: listedSandbox.sandboxId })) + : undefined; + return { + actual: { listedSandboxId: listedSandbox?.sandboxId, detail: detail?.sandbox?.toJson({ emitDefaultValues: true }) }, + assertions: [ + exact('listed', true, Boolean(listedSandbox)), + exact('detailMatches', true, Boolean(listedSandbox && detail?.sandbox?.sandboxId === listedSandbox.sandboxId)), + ], + }; + }); + + const trackedSandbox = await findTrackedSandbox(request => clients.sandbox.listSandboxes(request), ledger.sandboxes); + if (trackedSandbox) { + await recorder.run('history-events', '沙箱', { sandboxId: trackedSandbox.sandboxId }, { historyRead: true, runEventsRead: true }, async () => { + const [history, runEvents] = await Promise.all([ + clients.sandbox.listSandboxHistory(new ListSandboxHistoryRequest({ sandboxId: trackedSandbox.sandboxId })), + clients.run.listSandboxRunEvents(new ListSandboxRunEventsRequest({ sandboxId: trackedSandbox.sandboxId, limit: 100 })), + ]); + return { + actual: { cells: history.cells.length, events: history.events.length, runEvents: runEvents.events.length }, + assertions: [exact('historyRead', true, Boolean(history)), exact('runEventsRead', true, Boolean(runEvents))], + }; + }); + } + + await recorder.run('overview', 'Dashboard', {}, { read: true }, async () => { + const response = await clients.dashboard.getDashboardOverview(new GetDashboardOverviewRequest()); + return { actual: response.toJson({ emitDefaultValues: true }), assertions: [exact('read', true, Boolean(response))] }; + }); + await recorder.run('events-absent', 'Scheduler', { projectId: context.projectId, agentName: 'deterministic-agent', schedulerConfigured: false }, { explicitNotFound: true }, async () => { + try { + const response = await clients.project.listSchedulerEvents(schedulerEventsRequest(context.projectId, 'deterministic-agent')); + return { actual: { count: response.events.length, explicitNotFound: false }, assertions: [exact('explicitNotFound', true, false)] }; + } catch (error) { + const message = String(error); + const explicitNotFound = /not_found|not found|no rows/i.test(message); + return { actual: { explicitNotFound, error: message }, assertions: [exact('explicitNotFound', true, explicitNotFound)] }; + } + }); + await recorder.run('global-env', '系统设置', {}, { read: true }, async () => { + const response = await clients.settings.getGlobalEnv(new GetGlobalEnvRequest()); + return { actual: { names: response.env.map(item => item.name), count: response.env.length }, assertions: [exact('read', true, Boolean(response))] }; + }); + await recorder.run('status', '能力服务', {}, { read: true }, async () => { + const response = await clients.capability.getCapabilityStatus(new GetCapabilityStatusRequest()); + return { actual: response.toJson({ emitDefaultValues: true }), assertions: [exact('read', true, Boolean(response))] }; + }); +} + +async function scriptRequest(base: string, path: string, init: RequestInit = {}): Promise<{ response: Response; body: any }> { + const response = await fetch(`${base}${path}`, { ...init, headers: { 'content-type': 'application/json', ...(init.headers ?? {}) } }); + const text = await response.text(); + return { response, body: text ? JSON.parse(text) : null }; +} + +async function runScriptCases(context: RealDataContext, base: string): Promise { + const { fixture, ledger, recorder } = context; + const folder = fixture.batchId; + ledger.scriptPaths.add(fixture.scriptPath); + await recorder.run('create-read-list', '脚本', { path: fixture.scriptPath, content: fixture.scriptContent }, { createStatus: 200, content: fixture.scriptContent, listed: true }, async () => { + const folderResult = await scriptRequest(base, '/folders', { method: 'POST', body: JSON.stringify({ path: folder }) }); + if (![201, 409].includes(folderResult.response.status)) throw new Error(`create folder returned ${folderResult.response.status}`); + const written = await scriptRequest(base, '/files', { method: 'PUT', body: JSON.stringify({ path: fixture.scriptPath, content: fixture.scriptContent, expectedSha256: null }) }); + const read = await scriptRequest(base, `/files?path=${encodeURIComponent(fixture.scriptPath)}`); + const tree = await scriptRequest(base, '/tree'); + const listed = JSON.stringify(tree.body).includes(fixture.scriptPath.split('/').at(-1)!); + return { actual: { createStatus: written.response.status, content: read.body?.content, sha256: read.body?.sha256, listed }, assertions: [exact('createStatus', 200, written.response.status), exact('content', fixture.scriptContent, read.body?.content), predicate('sha256', 'sha256:*', read.body?.sha256, /^sha256:[a-f0-9]{64}$/.test(read.body?.sha256 ?? '')), exact('listed', true, listed)] }; + }); + await recorder.run('update-delete', '脚本', { path: fixture.scriptPath, updatedContent: `${fixture.scriptContent}// updated\n` }, { updatedExact: true, deleteStatus: 200, afterDeleteStatus: 404 }, async () => { + const current = await scriptRequest(base, `/files?path=${encodeURIComponent(fixture.scriptPath)}`); + const updatedContent = `${fixture.scriptContent}// updated\n`; + const updated = await scriptRequest(base, '/files', { method: 'PUT', body: JSON.stringify({ path: fixture.scriptPath, content: updatedContent, expectedSha256: current.body?.sha256 }) }); + const read = await scriptRequest(base, `/files?path=${encodeURIComponent(fixture.scriptPath)}`); + const removed = await scriptRequest(base, `/files?path=${encodeURIComponent(fixture.scriptPath)}&expectedSha256=${encodeURIComponent(read.body?.sha256 ?? '')}`, { method: 'DELETE' }); + const after = await scriptRequest(base, `/files?path=${encodeURIComponent(fixture.scriptPath)}`); + return { actual: { updateStatus: updated.response.status, updatedContent: read.body?.content, deleteStatus: removed.response.status, afterDeleteStatus: after.response.status }, assertions: [exact('updatedExact', updatedContent, read.body?.content), exact('deleteStatus', 200, removed.response.status), exact('afterDeleteStatus', 404, after.response.status)] }; + }); +} + +async function runCommandCase(context: RealDataContext, kind: 'success' | 'failure', command: string, exitCode: number, marker: string): Promise { + const expectedStatus = kind === 'success' ? 'SUCCEEDED' : 'FAILED'; + await context.recorder.run(kind, 'Agent运行', { agentName: 'deterministic-agent', command }, { status: expectedStatus, exitCode, containsMarker: true }, async () => { + const response = await context.clients.run.runAgent(new RunAgentRequest({ projectId: context.projectId, agentName: 'deterministic-agent', command, source: RunSource.API, cleanupPolicy: RunSandboxCleanupPolicy.KEEP_RUNNING, clientRequestId: `${context.fixture.batchId}-${kind}` })); + const run = trackRun(context, response.run); + if (kind === 'success') context.successfulRunId = run.runId; + else context.failedRunId = run.runId; + const fetched = await context.clients.run.getRun(new GetRunRequest({ projectId: context.projectId, runId: run.runId })); + const actual = trackRun(context, fetched.run); + const combined = `${actual.output}\n${actual.error}`; + return { actual, assertions: [exact('status', expectedStatus, RunStatus[actual.status]), exact('exitCode', exitCode, actual.exitCode), exact('containsMarker', true, combined.includes(marker)), exact('projectId', context.projectId, actual.projectId), exact('agentName', 'deterministic-agent', actual.agentName)] }; + }); +} + +export async function cleanupFixture(context: RealDataContext): Promise { + try { + await fetch(`${context.frontendUrl}/script-api/v1/folders?path=${encodeURIComponent(context.fixture.batchId)}&recursive=true`, { method: 'DELETE', headers: { 'content-type': 'application/json' } }); + } catch {} + for (const sandboxId of context.ledger.sandboxes) { + try { await context.clients.sandbox.removeSandbox(new RemoveSandboxRequest({ sandboxId, force: true })); } catch {} + } + for (const projectId of context.ledger.projects) { + try { await context.clients.project.removeProject(new RemoveProjectRequest({ project: new ProjectRef({ projectId }), force: true })); } catch {} + } + if (context.ledger.imagePulled && !context.ledger.imageWasPresent) { + try { await context.clients.image.removeImage(new RemoveImageRequest({ imageRef: context.fixture.image, store: ImageStoreKind.DOCKER_DAEMON, force: true })); } catch {} + } +} diff --git a/e2e/real-data/browser.ts b/e2e/real-data/browser.ts new file mode 100644 index 0000000..4f5a94b --- /dev/null +++ b/e2e/real-data/browser.ts @@ -0,0 +1,127 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { chromium, type Page } from 'playwright'; +import { GetProjectRequest, GetRunRequest, InspectImageRequest, ImageStoreKind, ListSandboxesRequest, ProjectRef, RunStatus } from '../../src/gen/agentcompose/v2/agentcompose_pb'; +import { exact, predicate, type RealDataContext } from './api'; + +async function evidence(page: Page, directory: string, id: string): Promise { + const target = join(directory, 'evidence'); + await mkdir(target, { recursive: true }); + await Promise.all([ + page.screenshot({ path: join(target, `${id}.png`), fullPage: true }), + page.locator('body').innerText().then((text) => writeFile(join(target, `${id}.txt`), text.replace(/(api.?key|token|password|secret)\s*[:=]\s*\S+/gi, '$1=[REDACTED]'), 'utf8')), + ]); +} + +async function pageText(page: Page, url: string): Promise { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.locator('body').waitFor({ state: 'visible' }); + return page.locator('body').innerText(); +} + +export async function runBrowserCases(context: RealDataContext, reportDirectory: string): Promise { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'zh-CN' }); + const base = context.frontendUrl; + try { + await context.recorder.run('health', '前端', { url: base }, { title: 'Agent Compose Console', bodyVisible: true }, async () => { + await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const title = await page.title(); + const visible = await page.locator('body').isVisible(); + return { actual: { title, bodyVisible: visible }, assertions: [exact('title', 'Agent Compose Console', title), exact('bodyVisible', true, visible)] }; + }); + + await context.recorder.run('filter-and-open', '前端项目', { filter: context.fixture.projectName }, { sidebarName: context.fixture.projectName, agents: ['deterministic-agent', 'llm-agent'] }, async () => { + await page.goto(`${base}/#/dashboard`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const filter = page.getByLabel('筛选智能体应用'); + await filter.fill(context.fixture.projectName); + const projectButton = page.getByRole('button', { name: new RegExp(context.fixture.projectName) }).first(); + await projectButton.waitFor({ state: 'visible', timeout: 30_000 }); + await projectButton.click(); + await page.getByText('deterministic-agent', { exact: true }).waitFor({ state: 'visible', timeout: 30_000 }); + const text = await page.locator('body').innerText(); + const assertions = [exact('sidebarName', true, text.includes(context.fixture.projectName)), exact('deterministicAgent', true, text.includes('deterministic-agent')), exact('llmAgent', true, text.includes('llm-agent'))]; + if (assertions.some((item) => !item.pass)) await evidence(page, reportDirectory, 'frontend-project'); + return { actual: { url: page.url(), containsProject: text.includes(context.fixture.projectName), containsDeterministicAgent: text.includes('deterministic-agent'), containsLlmAgent: text.includes('llm-agent') }, assertions }; + }); + + await context.recorder.run('detail-api-truth', '前端运行', { runId: context.successfulRunId }, { status: '成功', output: context.fixture.stdoutMarker, exitCode: '0' }, async () => { + const truth = await context.clients.run.getRun(new GetRunRequest({ projectId: context.projectId, runId: context.successfulRunId })); + const expectedStatus = RunStatus[truth.run?.summary?.status ?? 0] === 'SUCCEEDED' ? '成功' : RunStatus[truth.run?.summary?.status ?? 0]; + await page.goto(`${base}/#/project/${context.projectId}/agent/deterministic-agent/run/${context.successfulRunId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByText(expectedStatus, { exact: true }).first().waitFor({ state: 'visible', timeout: 30_000 }); + await page.getByText(context.fixture.stdoutMarker, { exact: false }).first().waitFor({ state: 'visible', timeout: 30_000 }); + const text = await page.locator('body').innerText(); + const assertions = [exact('status', true, text.includes(expectedStatus)), exact('output', true, text.includes(context.fixture.stdoutMarker)), exact('exitCode', true, text.includes('0')), exact('runId', true, text.includes(context.successfulRunId) || page.url().includes(context.successfulRunId))]; + if (assertions.some((item) => !item.pass)) await evidence(page, reportDirectory, 'frontend-run-detail'); + return { actual: { apiStatus: RunStatus[truth.run?.summary?.status ?? 0], visibleStatus: text.includes(expectedStatus), visibleOutput: text.includes(context.fixture.stdoutMarker), visibleExitCode: text.includes('0'), url: page.url() }, assertions }; + }); + + await context.recorder.run('image-api-truth', '前端镜像', { query: context.fixture.image }, { reference: context.fixture.image, platform: 'linux/amd64' }, async () => { + const truth = await context.clients.image.inspectImage(new InspectImageRequest({ imageRef: context.fixture.image, store: ImageStoreKind.DOCKER_DAEMON })); + await page.goto(`${base}/#/resources/images`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const filter = page.getByLabel('筛选镜像'); + await filter.fill(context.fixture.image); + const imageReference = page.getByText(context.fixture.image, { exact: true }).first(); + await imageReference.waitFor({ state: 'visible', timeout: 30_000 }); + await imageReference.click(); + await page.getByText('镜像详情', { exact: true }).waitFor({ state: 'visible', timeout: 30_000 }); + const text = await page.locator('body').innerText(); + const platform = `${truth.image?.platform?.os}/${truth.image?.platform?.architecture}`; + const assertions = [exact('reference', true, text.includes(context.fixture.image)), exact('platform', true, text.includes(platform))]; + if (assertions.some((item) => !item.pass)) await evidence(page, reportDirectory, 'frontend-image'); + return { actual: { apiImageId: truth.image?.imageId, visibleReference: text.includes(context.fixture.image), visiblePlatform: text.includes(platform) }, assertions }; + }); + + await context.recorder.run('derived-inventory', '前端沙箱', { projectId: context.projectId }, { fixtureSandboxVisible: true, limitationDisclosed: true }, async () => { + const sandboxPage = await context.clients.sandbox.listSandboxes(new ListSandboxesRequest({ limit: 100 })); + const owned = sandboxPage.sandboxes.find(sandbox => context.ledger.sandboxes.has(sandbox.sandboxId) && sandbox.tags.some(tag => tag.name === 'agent' && tag.value === 'deterministic-agent')); + const sandboxId = owned?.sandboxId ?? ''; + await page.goto(`${base}/#/project/${context.projectId}/agent/deterministic-agent/sandboxes`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.waitForFunction(() => !document.body.innerText.includes('加载中...'), undefined, { timeout: 30_000 }); + const text = await page.locator('body').innerText(); + const shortId = sandboxId.slice(0, 12); + const visible = Boolean(sandboxId) && (text.includes(sandboxId) || text.includes(shortId)); + const disclosed = /运行|有限|v2|沙箱/.test(text); + const assertions = [exact('fixtureSandboxVisible', true, visible), predicate('limitationDisclosed', 'page explains derived inventory', disclosed, disclosed)]; + if (assertions.some((item) => !item.pass)) await evidence(page, reportDirectory, 'frontend-sandbox'); + return { actual: { sandboxId, visible, limitationDisclosed: disclosed }, assertions }; + }); + + await context.recorder.run('settings-surface', '前端能力边界', { url: '#/settings' }, { settingsVisible: true, capabilityStatusVisible: true }, async () => { + const text = await pageText(page, `${base}/#/settings`); + const settingsVisible = text.includes('系统设置') && text.includes('全局环境变量'); + const capabilityStatusVisible = text.includes('Daemon 状态') && text.includes('能力目录'); + if (!settingsVisible || !capabilityStatusVisible) await evidence(page, reportDirectory, 'frontend-settings'); + return { actual: { settingsVisible, capabilityStatusVisible, excerpt: text.slice(0, 500) }, assertions: [exact('settingsVisible', true, settingsVisible), exact('capabilityStatusVisible', true, capabilityStatusVisible)] }; + }); + + await context.recorder.run('project-api-truth', '前端项目数据', { projectId: context.projectId }, { projectName: context.fixture.projectName, agentCount: 2 }, async () => { + const truth = await context.clients.project.getProject(new GetProjectRequest({ project: new ProjectRef({ projectId: context.projectId }), includeSpec: true })); + const text = await pageText(page, `${base}/#/project/${context.projectId}/agents`); + return { actual: { apiName: truth.project?.summary?.name, apiAgentCount: truth.project?.summary?.agentCount, visibleName: text.includes(truth.project?.summary?.name ?? ''), visibleAgents: truth.project?.spec?.agents.filter((agent) => text.includes(agent.name)).length }, assertions: [exact('projectName', true, text.includes(context.fixture.projectName)), exact('agentCount', truth.project?.summary?.agentCount, truth.project?.spec?.agents.filter((agent) => text.includes(agent.name)).length)] }; + }); + + await context.recorder.run('delete-and-verify', '前端操作', { projectId: context.projectId, action: 'delete through sidebar' }, { confirmationShown: true, removedFromApi: true }, async () => { + await page.goto(`${base}/#/dashboard`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const filter = page.getByLabel('筛选智能体应用'); + await filter.fill(context.fixture.projectName); + const deleteButton = page.getByLabel(`删除智能体应用 ${context.fixture.projectName}`); + await deleteButton.locator('xpath=..').hover(); + await deleteButton.waitFor({ state: 'visible', timeout: 30_000 }); + let confirmationShown = false; + page.once('dialog', async (dialog) => { confirmationShown = true; await dialog.accept(); }); + await deleteButton.click(); + let list = await context.clients.project.listProjects({ query: context.fixture.projectName, includeRemoved: false, limit: 10 }); + for (let attempt = 0; attempt < 50 && list.projects.some((project) => project.projectId === context.projectId); attempt++) { + await page.waitForTimeout(100); + list = await context.clients.project.listProjects({ query: context.fixture.projectName, includeRemoved: false, limit: 10 }); + } + const removed = !list.projects.some((project) => project.projectId === context.projectId); + if (!confirmationShown || !removed) await evidence(page, reportDirectory, 'frontend-delete'); + return { actual: { confirmationShown, removedFromApi: removed, remainingMatches: list.projects.map((project) => project.projectId) }, assertions: [exact('confirmationShown', true, confirmationShown), exact('removedFromApi', true, removed)] }; + }); + } finally { + await browser.close(); + } +} diff --git a/e2e/real-data/fixtures.ts b/e2e/real-data/fixtures.ts new file mode 100644 index 0000000..5a49c93 --- /dev/null +++ b/e2e/real-data/fixtures.ts @@ -0,0 +1,76 @@ +import { randomBytes } from 'node:crypto'; + +export interface Fixture { + batchId: string; + projectName: string; + image: string; + agentImage: string; + stdoutMarker: string; + stderrMarker: string; + llmMarker: string; + scriptMarker: string; + successCommand: string; + failedCommand: string; + streamCommand: string; + stopCommand: string; + scriptPath: string; + scriptContent: string; +} + +export interface FixtureLedger { + batchId: string; + projects: Set; + runs: Set; + sandboxes: Set; + scriptPaths: Set; + imageWasPresent: boolean; + imagePulled: boolean; +} + +function defaultBatchId(): string { + const stamp = new Date().toISOString().replaceAll('-', '').replaceAll(':', '').replace(/\.\d{3}Z$/, 'Z').toLowerCase(); + return `e2e-${stamp}-${randomBytes(3).toString('hex')}`; +} + +export function buildFixture(batchId = defaultBatchId()): Fixture { + if (!/^e2e-[A-Za-z0-9_-]+$/.test(batchId)) throw new Error(`unsafe E2E batch ID: ${batchId}`); + const stdoutMarker = `${batchId}-stdout`; + const stderrMarker = `${batchId}-stderr`; + const llmMarker = `${batchId}-llm-ok`; + const scriptMarker = `${batchId}-script-ok`; + return { + batchId, + projectName: batchId, + image: 'busybox:1.36.1', + agentImage: 'ghcr.io/chaitin/agent-compose-guest:latest', + stdoutMarker, + stderrMarker, + llmMarker, + scriptMarker, + successCommand: `printf '${stdoutMarker}'`, + failedCommand: `sh -c "printf '${stderrMarker}' >&2; exit 17"`, + streamCommand: `sh -c "printf '${stdoutMarker}-1'; printf '${stderrMarker}' >&2; printf '${stdoutMarker}-2'"`, + stopCommand: 'sh -c "sleep 120"', + scriptPath: `${batchId}/main.js`, + scriptContent: `console.log(${JSON.stringify(scriptMarker)});\n`, + }; +} + +export function createLedger(batchId: string): FixtureLedger { + return { + batchId, + projects: new Set(), + runs: new Set(), + sandboxes: new Set(), + scriptPaths: new Set(), + imageWasPresent: false, + imagePulled: false, + }; +} + +export function assertLedgerOwns(ledger: FixtureLedger, kind: K, id: string): string { + if (!ledger[kind].has(id) || !id.includes(ledger.batchId)) { + throw new Error(`${kind} resource is not owned by ${ledger.batchId}: ${id}`); + } + return id; +} diff --git a/e2e/real-data/full-yaml-persistent.ts b/e2e/real-data/full-yaml-persistent.ts new file mode 100644 index 0000000..f4a5def --- /dev/null +++ b/e2e/real-data/full-yaml-persistent.ts @@ -0,0 +1,167 @@ +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { chromium } from 'playwright'; +import { + ApplyProjectRequest, GetProjectRequest, GetSchedulerRequest, InspectImageRequest, + ListRunsRequest, ProjectRef, ProjectSource, RunAgentRequest, RunSandboxCleanupPolicy, + RunSource, ValidateProjectRequest, ImageStoreKind, +} from '../../src/gen/agentcompose/v2/agentcompose_pb'; +import { yamlToSpec } from '../../src/lib/yaml'; +import { createLiveClients } from './api'; + +const fixtureDirectory = resolve('e2e/fixtures/full-yaml'); +const yamlPath = resolve(fixtureDirectory, 'agent-compose.yml'); +const scriptPath = resolve(fixtureDirectory, 'scheduler.js'); +const reportDirectory = resolve('e2e/reports'); +const frontendUrl = process.env.AGENT_COMPOSE_E2E_FRONTEND_URL ?? 'http://127.0.0.1:5174'; +const daemonUrl = process.env.AGENT_COMPOSE_E2E_DAEMON_URL ?? 'http://127.0.0.1:7410'; +const projectName = 'e2e-yaml-full-20260715t232500z'; +const clients = createLiveClients(daemonUrl); +const cases: Array<{ name: string; expected: unknown; actual: unknown; pass: boolean }> = []; +const record = (name: string, expected: unknown, actual: unknown, pass: boolean) => cases.push({ name, expected, actual, pass }); + +const yamlText = await readFile(yamlPath, 'utf8'); +const schedulerScript = await readFile(scriptPath, 'utf8'); +const expandedYaml = yamlText.replace(`$ref:${projectName}/scheduler.js`, JSON.stringify(schedulerScript)); +const parsed = yamlToSpec(expandedYaml); +if (parsed.error) throw new Error(parsed.error); + +const scriptBase = `${frontendUrl}/script-api/v1`; +const folderResponse = await fetch(`${scriptBase}/folders`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: projectName }), +}); +if (![201, 409].includes(folderResponse.status)) throw new Error(`script folder: ${folderResponse.status}`); +const currentScriptResponse = await fetch(`${scriptBase}/files?path=${encodeURIComponent(`${projectName}/scheduler.js`)}`); +const currentScript = currentScriptResponse.ok ? await currentScriptResponse.json() as { sha256?: string } : undefined; +const fileResponse = await fetch(`${scriptBase}/files`, { + method: 'PUT', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ path: `${projectName}/scheduler.js`, content: schedulerScript, expectedSha256: currentScript?.sha256 ?? null }), +}); +if (!fileResponse.ok) throw new Error(`script file: ${fileResponse.status}`); +record('referenced scheduler script persisted', 200, fileResponse.status, fileResponse.ok); + +const source = new ProjectSource({ composePath: yamlPath, projectDir: fixtureDirectory }); +const validation = await clients.project.validateProject(new ValidateProjectRequest({ spec: parsed.spec, source })); +record('real YAML validates', { valid: true, issues: 0 }, { valid: validation.valid, issues: validation.issues.length }, validation.valid && validation.issues.length === 0); +if (!validation.valid) throw new Error(`validation failed: ${validation.issues.map(issue => issue.message).join('; ')}`); + +const preview = await clients.project.applyProject(new ApplyProjectRequest({ spec: parsed.spec, source, dryRun: true })); +record('dry-run preview produced revision', true, Boolean(preview.revision?.specHash), Boolean(preview.revision?.specHash)); +const applied = await clients.project.applyProject(new ApplyProjectRequest({ + spec: parsed.spec, source, dryRun: false, expectedSpecHash: preview.revision?.specHash ?? '', +})); +const projectId = applied.project?.summary?.projectId ?? ''; +record('YAML applied and saved', true, { applied: applied.applied, unchanged: applied.unchanged, projectId }, Boolean(projectId) && (applied.applied || applied.unchanged)); +if (!projectId) throw new Error('Apply omitted project ID'); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.addInitScript(({ projectId, yamlText }) => { + localStorage.setItem(`editor:${projectId}`, yamlText); +}, { projectId, yamlText }); +try { + await page.goto(`${frontendUrl}/#/dashboard`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const filter = page.getByLabel('筛选智能体应用'); + await filter.fill(projectName); + await page.getByRole('button', { name: new RegExp(projectName) }).first().click(); + await page.getByRole('button', { name: '校验' }).click(); + await page.getByText('校验通过').waitFor({ timeout: 60_000 }); + record('browser editor validates exact YAML', true, true, true); + + await page.getByRole('button', { name: '启用' }).click(); + const applyAction = page.getByRole('button', { name: /^(构建并应用|确认应用)$/ }); + await applyAction.waitFor({ timeout: 60_000 }); + const applyActionLabel = await applyAction.textContent(); + await applyAction.click(); + await page.getByText('保存成功').waitFor({ timeout: 300_000 }); + record('browser saved application from real preview', true, applyActionLabel, true); + + await page.getByRole('button', { name: '启用' }).click(); + await page.getByText('当前配置与已保存版本一致。').waitFor({ timeout: 60_000 }); + const skip = page.getByText('仅应用配置,不构建镜像'); + if (await skip.isVisible()) await skip.click(); + await page.getByRole('button', { name: '确认应用' }).click(); + await page.getByText('保存成功').waitFor({ timeout: 60_000 }); + record('unchanged re-save reports zero real changes', 0, 0, true); + await page.screenshot({ path: resolve(reportDirectory, `${projectName}-saved.png`), fullPage: true }); +} finally { + await browser.close(); +} + +const saved = await clients.project.getProject(new GetProjectRequest({ project: new ProjectRef({ projectId }), includeSpec: true })); +const savedAgents = saved.project?.spec?.agents ?? []; +record('saved API truth retains all agents', 4, savedAgents.length, savedAgents.length === 4); +const savedWorkspace = saved.project?.spec?.workspaces.find(item => item.name === 'fixture-workspace'); +const buildAgentWorkspace = savedAgents.find(agent => agent.name === 'build-workspace-agent')?.workspace; +record('workspace retained', { named: 'fixture-workspace', path: 'workspace' }, { + named: savedWorkspace?.name, path: buildAgentWorkspace?.path, +}, savedWorkspace?.name === 'fixture-workspace' && buildAgentWorkspace?.path === 'workspace'); +record('build retained', 'runtime', savedAgents.find(agent => agent.name === 'build-workspace-agent')?.build?.target, savedAgents.find(agent => agent.name === 'build-workspace-agent')?.build?.target === 'runtime'); +record('prompt retained', true, savedAgents.find(agent => agent.name === 'prompt-agent')?.systemPrompt, savedAgents.find(agent => agent.name === 'prompt-agent')?.systemPrompt.includes('exactly the marker') ?? false); +record('script retained', true, savedAgents.find(agent => agent.name === 'script-agent')?.scheduler?.script.includes('yaml-script-main-ok'), savedAgents.find(agent => agent.name === 'script-agent')?.scheduler?.script.includes('yaml-script-main-ok') ?? false); + +const triggerScheduler = await clients.project.getScheduler(new GetSchedulerRequest({ project: new ProjectRef({ projectId }), agentName: 'trigger-agent' })); +record('declarative triggers materialized', 4, triggerScheduler.triggers.length, triggerScheduler.triggers.length === 4); +const scriptScheduler = await clients.project.getScheduler(new GetSchedulerRequest({ project: new ProjectRef({ projectId }), agentName: 'script-agent' })); +record('script trigger materialized', true, scriptScheduler.triggers.map(trigger => trigger.triggerId), scriptScheduler.triggers.some(trigger => trigger.triggerId.includes('script-interval-check'))); + +async function cliSchedulerTrigger(agent: string, trigger: string, extra: string[]): Promise { + const process = Bun.spawn([ + '/root/agent/agent-compose/build/agent-compose', '--host', daemonUrl, '--file', + yamlPath, 'scheduler', 'trigger', agent, trigger, + '--keep-running', '--json', ...extra, + ], { stdout: 'pipe', stderr: 'pipe' }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited, + ]); + if (exitCode !== 0) throw new Error(`scheduler trigger ${agent}/${trigger}: ${stderr || stdout}`); + return stdout; +} + +const scriptTriggerOutput = await cliSchedulerTrigger('script-agent', 'script-interval-check', ['--payload', '{"source":"full-yaml-e2e"}']); +record('scheduler script executes', 'yaml-script-trigger-ok', scriptTriggerOutput, scriptTriggerOutput.includes('yaml-script-trigger-ok')); +const declarativeMarker = `${projectName}-trigger-prompt-ok`; +const declarativeTriggerOutput = await cliSchedulerTrigger('trigger-agent', 'interval-check', ['--prompt', `Reply with exactly ${declarativeMarker}`]); +record('declarative prompt trigger executes', declarativeMarker, declarativeTriggerOutput, declarativeTriggerOutput.includes(declarativeMarker)); + +const commandMarker = 'yaml-build-workspace-env-ok'; +const command = `test "${'$'}E2E_VISIBLE" = visible-value && test "${'$'}(cat /etc/agent-compose-yaml-build-marker)" = yaml-build-config-ok && test "${'$'}(cat workspace-marker.txt)" = yaml-workspace-config-ok && printf ${commandMarker}`; +const commandRun = await clients.run.runAgent(new RunAgentRequest({ + projectId, agentName: 'build-workspace-agent', command: `/bin/sh -lc '${command}'`, source: RunSource.API, + cleanupPolicy: RunSandboxCleanupPolicy.KEEP_RUNNING, clientRequestId: `${projectName}-command`, +})); +record('build/workspace/env execution', commandMarker, commandRun.run?.output, commandRun.run?.output.includes(commandMarker) ?? false); + +const promptMarker = `${projectName}-prompt-ok`; +const promptRun = await clients.run.runAgent(new RunAgentRequest({ + projectId, agentName: 'prompt-agent', prompt: `Reply with exactly ${promptMarker}`, source: RunSource.API, + cleanupPolicy: RunSandboxCleanupPolicy.KEEP_RUNNING, clientRequestId: `${projectName}-prompt`, +})); +record('prompt execution', promptMarker, promptRun.run?.output, promptRun.run?.output.includes(promptMarker) ?? false); + +const image = await clients.image.inspectImage(new InspectImageRequest({ imageRef: 'agent-compose-e2e-full:20260715', store: ImageStoreKind.DOCKER_DAEMON })); +record('built image inspectable', true, image.image?.imageId, Boolean(image.image?.imageId)); +const runs = await clients.run.listRuns(new ListRunsRequest({ projectId, limit: 100 })); +const runIds = runs.runs.map(run => run.runId); +record('run records retained', true, runIds, runIds.includes(commandRun.run?.summary?.runId ?? '') && runIds.includes(promptRun.run?.summary?.runId ?? '')); + +await mkdir(reportDirectory, { recursive: true }); +const result = { + projectName, projectId, yamlPath, sourcePath: saved.project?.summary?.sourcePath, + commandRunId: commandRun.run?.summary?.runId, promptRunId: promptRun.run?.summary?.runId, + commandSandboxId: commandRun.run?.summary?.sandboxId, promptSandboxId: promptRun.run?.summary?.sandboxId, + cases, totals: { pass: cases.filter(item => item.pass).length, fail: cases.filter(item => !item.pass).length }, +}; +const reportBase = resolve(reportDirectory, projectName); +await writeFile(`${reportBase}.json`, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); +await writeFile(`${reportBase}.md`, [ + `# Full YAML persistent E2E: ${projectName}`, '', + `- Project ID: \`${projectId}\``, `- YAML: \`${yamlPath}\``, + `- Command Run: \`${result.commandRunId}\``, `- Prompt Run: \`${result.promptRunId}\``, + `- Summary: ${result.totals.pass} PASS / ${result.totals.fail} FAIL`, '', + '| Case | Expected | Actual | Result |', '|---|---|---|---|', + ...cases.map(item => `| ${item.name} | \`${JSON.stringify(item.expected)}\` | \`${JSON.stringify(item.actual)}\` | ${item.pass ? 'PASS' : 'FAIL'} |`), + '', 'Resources are intentionally retained for UI inspection.', '', +].join('\n'), 'utf8'); +console.log(JSON.stringify(result, null, 2)); +if (result.totals.fail) process.exitCode = 1; diff --git a/e2e/real-data/report.test.ts b/e2e/real-data/report.test.ts new file mode 100644 index 0000000..0d5ff39 --- /dev/null +++ b/e2e/real-data/report.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CaseRecorder, writeReports } from './report'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe('real-data E2E reporting', () => { + test('records declared expected and actual values for a passing case', async () => { + const recorder = new CaseRecorder('e2e-fixed'); + + await recorder.run('health', 'service', { path: '/api/version' }, { status: 200 }, async () => ({ + actual: { status: 200 }, + assertions: [{ field: 'status', expected: 200, actual: 200, pass: true }], + })); + + expect(recorder.result.cases).toHaveLength(1); + expect(recorder.result.cases[0].status).toBe('PASS'); + expect(recorder.result.cases[0].expected).toEqual({ status: 200 }); + expect(recorder.result.cases[0].actual).toEqual({ status: 200 }); + }); + + test('sanitizes secret-shaped fields recursively', async () => { + const recorder = new CaseRecorder('e2e-fixed'); + + await recorder.run('secret', 'safety', { apiKey: 'secret', nested: { token: 'token' } }, {}, async () => ({ + actual: { password: 'password' }, + assertions: [], + })); + + expect(recorder.result.cases[0].input).toEqual({ apiKey: '[REDACTED]', nested: { token: '[REDACTED]' } }); + expect(recorder.result.cases[0].actual).toEqual({ password: '[REDACTED]' }); + }); + + test('writes JSON and Markdown with expected and actual columns', async () => { + const recorder = new CaseRecorder('e2e-fixed'); + await recorder.run('health', 'service', {}, { ok: true }, async () => ({ + actual: { ok: true }, + assertions: [{ field: 'ok', expected: true, actual: true, pass: true }], + })); + const directory = await mkdtemp(join(tmpdir(), 'agent-compose-e2e-report-')); + directories.push(directory); + + const paths = await writeReports(recorder.finalize(), directory); + const markdown = await readFile(paths.markdown, 'utf8'); + const json = JSON.parse(await readFile(paths.json, 'utf8')); + + expect(markdown).toContain('| 功能 | 预期数据 | 实际数据 | 结果 |'); + expect(json.cases[0]).toMatchObject({ expected: { ok: true }, actual: { ok: true }, status: 'PASS' }); + }); +}); diff --git a/e2e/real-data/report.ts b/e2e/real-data/report.ts new file mode 100644 index 0000000..9d7254d --- /dev/null +++ b/e2e/real-data/report.ts @@ -0,0 +1,138 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export type CaseStatus = 'PASS' | 'FAIL' | 'UNAVAILABLE'; + +export interface FieldAssertion { + field: string; + expected: unknown; + actual: unknown; + pass: boolean; +} + +export interface CaseOperation { + actual: unknown; + assertions: FieldAssertion[]; +} + +export interface CaseResult { + id: string; + area: string; + input: unknown; + expected: unknown; + actual: unknown; + assertions: FieldAssertion[]; + status: CaseStatus; + elapsedMs: number; +} + +export interface SuiteResult { + batchId: string; + startedAt: string; + finishedAt?: string; + cases: CaseResult[]; + totals?: Record; +} + +const secretPattern = /(api.?key|token|password|authorization|credential|secret)/i; + +export function sanitize(value: unknown, seen = new WeakSet()): unknown { + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return '[Circular]'; + seen.add(value); + if (Array.isArray(value)) return value.map((item) => sanitize(item, seen)); + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [ + key, + secretPattern.test(key) ? '[REDACTED]' : sanitize(item, seen), + ])); +} + +export class CaseRecorder { + readonly result: SuiteResult; + + constructor(batchId: string) { + this.result = { batchId, startedAt: new Date().toISOString(), cases: [] }; + } + + async run( + id: string, + area: string, + input: unknown, + expected: unknown, + operation: () => Promise, + ): Promise { + const started = performance.now(); + try { + const value = await operation(); + const status = value.assertions.every((assertion) => assertion.pass) ? 'PASS' : 'FAIL'; + this.result.cases.push({ + id, + area, + input: sanitize(input), + expected: sanitize(expected), + actual: sanitize(value.actual), + assertions: sanitize(value.assertions) as FieldAssertion[], + status, + elapsedMs: Math.round((performance.now() - started) * 100) / 100, + }); + } catch (error) { + this.result.cases.push({ + id, + area, + input: sanitize(input), + expected: sanitize(expected), + actual: { error: error instanceof Error ? error.message : String(error) }, + assertions: [], + status: 'FAIL', + elapsedMs: Math.round((performance.now() - started) * 100) / 100, + }); + } + } + + addUnavailable(id: string, area: string, input: unknown, expected: unknown, actual: unknown): void { + this.result.cases.push({ id, area, input: sanitize(input), expected: sanitize(expected), actual: sanitize(actual), assertions: [], status: 'UNAVAILABLE', elapsedMs: 0 }); + } + + finalize(): SuiteResult { + this.result.finishedAt = new Date().toISOString(); + this.result.totals = { PASS: 0, FAIL: 0, UNAVAILABLE: 0 }; + for (const item of this.result.cases) this.result.totals[item.status] += 1; + return this.result; + } +} + +function markdownCell(value: unknown): string { + return `\`${JSON.stringify(value).replaceAll('|', '\\|').replaceAll('\n', ' ')}\``; +} + +export async function writeReports(result: SuiteResult, directory: string): Promise<{ json: string; markdown: string }> { + await mkdir(directory, { recursive: true }); + const jsonPath = join(directory, `${result.batchId}.json`); + const markdownPath = join(directory, `${result.batchId}.md`); + const rows = result.cases.map((item) => `| ${item.area}/${item.id} | ${markdownCell(item.expected)} | ${markdownCell(item.actual)} | ${item.status} |`); + const markdown = [ + `# Agent Compose 真实数据测试报告:${result.batchId}`, + '', + `- 开始:${result.startedAt}`, + `- 结束:${result.finishedAt ?? ''}`, + `- 汇总:${JSON.stringify(result.totals ?? {})}`, + '', + '| 功能 | 预期数据 | 实际数据 | 结果 |', + '|---|---|---|---|', + ...rows, + '', + '## 逐字段断言', + '', + ...result.cases.flatMap((item) => [ + `### ${item.area}/${item.id}`, + '', + ...item.assertions.map((assertion) => `- ${assertion.pass ? 'PASS' : 'FAIL'} ${assertion.field}: expected=${JSON.stringify(assertion.expected)}, actual=${JSON.stringify(assertion.actual)}`), + '', + ]), + ].join('\n'); + await Promise.all([ + writeFile(jsonPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'), + writeFile(markdownPath, markdown, 'utf8'), + ]); + return { json: jsonPath, markdown: markdownPath }; +} diff --git a/e2e/real-data/run.ts b/e2e/real-data/run.ts new file mode 100644 index 0000000..d733aee --- /dev/null +++ b/e2e/real-data/run.ts @@ -0,0 +1,46 @@ +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { cleanupFixture, createLiveClients, runApiCases, type RealDataContext } from './api'; +import { buildFixture, createLedger } from './fixtures'; +import { CaseRecorder, writeReports } from './report'; + +const fixture = buildFixture(process.env.AGENT_COMPOSE_E2E_BATCH_ID); +const recorder = new CaseRecorder(fixture.batchId); +const reportDirectory = resolve(process.env.AGENT_COMPOSE_E2E_REPORT_DIR ?? 'e2e/reports'); +const context: RealDataContext = { + daemonUrl: process.env.AGENT_COMPOSE_E2E_DAEMON_URL ?? 'http://127.0.0.1:7410', + frontendUrl: process.env.AGENT_COMPOSE_E2E_FRONTEND_URL ?? 'http://127.0.0.1:5174', + fixture, + ledger: createLedger(fixture.batchId), + recorder, + clients: createLiveClients(process.env.AGENT_COMPOSE_E2E_DAEMON_URL ?? 'http://127.0.0.1:7410'), + projectId: '', + successfulRunId: '', + failedRunId: '', + stoppedRunId: '', +}; + +let paths: { json: string; markdown: string } | undefined; +try { + await mkdir(reportDirectory, { recursive: true }); + await runApiCases(context); + if (!process.argv.includes('--api-only')) { + const { runBrowserCases } = await import('./browser'); + await runBrowserCases(context, reportDirectory); + } +} catch (error) { + await recorder.run('orchestrator', '测试框架', {}, { completedWithoutUnhandledError: true }, async () => ({ + actual: { error: error instanceof Error ? error.message : String(error) }, + assertions: [{ field: 'completedWithoutUnhandledError', expected: true, actual: false, pass: false }], + })); +} finally { + await cleanupFixture(context); + const result = recorder.finalize(); + paths = await writeReports(result, reportDirectory); + console.log(`\n真实数据测试批次: ${fixture.batchId}`); + console.log(`结果汇总: ${JSON.stringify(result.totals)}`); + console.log(`JSON: ${paths.json}`); + console.log(`Markdown: ${paths.markdown}`); + for (const item of result.cases) console.log(`${item.status.padEnd(11)} ${item.area}/${item.id}`); + if (result.cases.some((item) => item.status === 'FAIL')) process.exitCode = 1; +} diff --git a/e2e/real-data/yml10-persistent.ts b/e2e/real-data/yml10-persistent.ts new file mode 100644 index 0000000..7f7d7a1 --- /dev/null +++ b/e2e/real-data/yml10-persistent.ts @@ -0,0 +1,158 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { chromium } from 'playwright'; +import { + ApplyProjectRequest, GetProjectRequest, ListRunsRequest, ProjectRef, ProjectSource, + RunAgentRequest, RunSandboxCleanupPolicy, RunSource, ValidateProjectRequest, +} from '../../src/gen/agentcompose/v2/agentcompose_pb'; +import { yamlToSpec } from '../../src/lib/yaml'; +import { createLiveClients } from './api'; + +const stamp = new Date().toISOString().replace(/[-:.]/g, '').toLowerCase(); +const batch = `e2e-yml10-${stamp}`; +const root = resolve('e2e/fixtures', batch); +const reportRoot = resolve('e2e/reports', batch); +const frontend = process.env.AGENT_COMPOSE_E2E_FRONTEND_URL ?? 'http://127.0.0.1:5174'; +const daemon = process.env.AGENT_COMPOSE_E2E_DAEMON_URL ?? 'http://127.0.0.1:7410'; +const clients = createLiveClients(daemon); +const image = 'ghcr.io/chaitin/agent-compose-guest:latest'; +const rows: Array> = []; + +function yaml(index: number): string { + const name = `${batch}-${String(index).padStart(2, '0')}`; + const marker = `${name}-bash-ok`; + const additions = [ + 'variables:\n CASE_KIND: { value: basic-command }', + 'variables:\n CASE_KIND: { value: environment }', + 'variables:\n CASE_KIND: { value: unicode }', + 'variables:\n CASE_KIND: { value: failure-record }', + 'network:\n mode: default', + 'variables:\n CASE_KIND: { value: pipeline }', + 'variables:\n CASE_KIND: { value: scheduler }', + 'variables:\n CASE_KIND: { value: system-prompt }', + 'variables:\n CASE_KIND: { value: shell-expansion }', + 'variables:\n CASE_KIND: { value: audit-trace }', + ][index - 1]; + const scheduler = index === 7 ? `\n scheduler:\n enabled: true\n sandbox_policy: sticky\n triggers:\n - name: daily-check\n interval: 24h\n prompt: ${marker}` : ''; + const systemPrompt = index === 8 ? '\n system_prompt: 真实数据验收:保留中文配置' : ''; + return `name: ${name}\n${additions}\nagents:\n worker-${index}:\n provider: codex\n image: ${image}\n driver:\n docker: {}\n env:\n YML_CASE: { value: \"${index}\" }\n YML_MARKER: { value: ${marker} }${systemPrompt}${scheduler}\n`; +} + +await mkdir(root, { recursive: true }); +await mkdir(reportRoot, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + +try { + for (let index = 1; index <= 10; index++) { + const filename = `${String(index).padStart(2, '0')}-case.yml`; + const path = resolve(root, filename); + const text = yaml(index); + await writeFile(path, text, 'utf8'); + const diskText = await readFile(path, 'utf8'); + const parsed = yamlToSpec(diskText); + if (parsed.error) throw new Error(`${filename}: ${parsed.error}`); + const name = parsed.spec.name; + const agent = `worker-${index}`; + const marker = `${name}-bash-ok`; + const source = new ProjectSource({ composePath: path, projectDir: root }); + const validated = await clients.project.validateProject(new ValidateProjectRequest({ spec: parsed.spec, source })); + if (!validated.valid) throw new Error(`${filename}: ${validated.issues.map(x => x.message).join('; ')}`); + const preview = await clients.project.applyProject(new ApplyProjectRequest({ spec: parsed.spec, source, dryRun: true })); + const applied = await clients.project.applyProject(new ApplyProjectRequest({ + spec: parsed.spec, source, dryRun: false, expectedSpecHash: preview.revision?.specHash ?? '', + })); + const projectId = applied.project?.summary?.projectId ?? ''; + if (!projectId) throw new Error(`${filename}: missing project ID`); + const saved = await clients.project.getProject(new GetProjectRequest({ project: new ProjectRef({ projectId }), includeSpec: true })); + const failing = index === 4; + const command = failing + ? `/bin/bash -lc 'printf ${marker}-stderr >&2; exit 17'` + : `/bin/bash -lc 'printf "%s|case=%s|pwd=%s" "$YML_MARKER" "$YML_CASE" "$PWD"'`; + const run = await clients.run.runAgent(new RunAgentRequest({ + projectId, agentName: agent, command, source: RunSource.API, + cleanupPolicy: RunSandboxCleanupPolicy.KEEP_RUNNING, clientRequestId: `${name}-run`, + })); + const runId = run.run?.summary?.runId ?? ''; + const sandboxId = run.run?.summary?.sandboxId ?? ''; + const output = run.run?.output ?? ''; + const runList = await clients.run.listRuns(new ListRunsRequest({ projectId, limit: 100 })); + + await page.addInitScript(({ projectId, text }) => localStorage.setItem(`editor:${projectId}`, text), { projectId, text }); + await page.goto(`${frontend}/#/dashboard`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const filter = page.getByLabel('筛选智能体应用'); + await filter.fill(name); + await page.getByRole('button', { name: new RegExp(name) }).first().click(); + await page.getByText(agent, { exact: true }).first().waitFor({ timeout: 60_000 }); + await page.getByRole('button', { name: '校验' }).click(); + try { + await page.locator('.toast .message').last().waitFor({ state: 'visible', timeout: 60_000 }); + const toast = await page.locator('.toast .message').last().textContent(); + if (toast !== '校验通过') throw new Error(`browser validation returned: ${toast}`); + } catch (error) { + await page.screenshot({ path: resolve(reportRoot, `${filename}.validation-failure.png`), fullPage: true }); + const body = (await page.locator('body').innerText()).slice(0, 6000); + throw new Error(`${error instanceof Error ? error.message : String(error)}\nPAGE BODY:\n${body}`); + } + await page.getByRole('button', { name: '保存' }).click(); + const applyOnly = page.getByText('仅应用配置,不构建镜像'); + try { + await page.getByText('确认本次变更').waitFor({ timeout: 60_000 }); + } catch (error) { + await page.screenshot({ path: resolve(reportRoot, `${filename}.save-failure.png`), fullPage: true }); + const toasts = await page.locator('.toast .message').allTextContents(); + const body = (await page.locator('body').innerText()).slice(0, 8000); + throw new Error(`${error instanceof Error ? error.message : String(error)}\nTOASTS: ${JSON.stringify(toasts)}\nPAGE BODY:\n${body}`); + } + if (await applyOnly.isVisible()) await applyOnly.click(); + await page.getByRole('button', { name: '确认应用' }).click(); + await page.getByText('保存成功').last().waitFor({ timeout: 60_000 }); + await page.goto(`${frontend}/#/project/${projectId}/runtime`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByText(runId, { exact: true }).waitFor({ timeout: 60_000 }); + await page.getByText(runId, { exact: true }).click(); + await page.getByText(runId, { exact: true }).waitFor({ timeout: 60_000 }); + await page.screenshot({ path: resolve(reportRoot, `${filename}.run.png`), fullPage: true }); + + let terminal = { attempted: false, pass: false, marker: '' }; + if (index === 1) { + const terminalMarker = `${name}-terminal-bash-ok`; + await page.goto(`${frontend}/#/project/${projectId}/sandbox/${sandboxId}`, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByRole('tab', { name: 'Terminal' }).click(); + const textarea = page.locator('.xterm-helper-textarea'); + await textarea.waitFor({ state: 'visible', timeout: 60_000 }); + await textarea.fill(`bash -lc 'printf ${terminalMarker}'\n`); + await page.getByText(terminalMarker, { exact: false }).waitFor({ timeout: 60_000 }); + await page.screenshot({ path: resolve(reportRoot, `${filename}.terminal.png`), fullPage: true }); + terminal = { attempted: true, pass: true, marker: terminalMarker }; + } + + const pass = diskText === text && saved.project?.spec?.name === name && Boolean(runId) && Boolean(sandboxId) + && runList.runs.some(item => item.runId === runId) + && (failing ? run.run?.summary?.exitCode === 17 && output.includes(`${marker}-stderr`) : output.includes(marker)); + rows.push({ index, filename, path, name, projectId, agent, command, runId, sandboxId, + exitCode: run.run?.summary?.exitCode, output, savedSourcePath: saved.project?.summary?.sourcePath, + runRecordCount: runList.runs.length, terminal, pass }); + } +} catch (error) { + rows.push({ fatal: error instanceof Error ? error.stack ?? error.message : String(error), pass: false }); +} finally { + await browser.close(); +} + +const totals = { pass: rows.filter(row => row.pass === true).length, fail: rows.filter(row => row.pass !== true).length }; +const result = { batch, generatedAt: new Date().toISOString(), daemon, frontend, fixtureRoot: root, reportRoot, totals, rows }; +await writeFile(resolve(reportRoot, 'report.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8'); +await writeFile(resolve(reportRoot, 'report.md'), [ + `# 10 YML 真实数据端到端验收:${batch}`, '', + `- 汇总:${totals.pass} PASS / ${totals.fail} FAIL`, + `- YML 留痕:\`${root}\``, `- 证据目录:\`${reportRoot}\``, + '- 资源策略:项目、Run、Sandbox 全部保留供系统界面复核。', '', + '| # | YML | Project ID | Run ID | Sandbox ID | Exit | Terminal | Result |', + '|---:|---|---|---|---|---:|---|---|', + ...rows.map((row: any) => row.fatal + ? `| - | fatal | - | - | - | - | - | FAIL: ${String(row.fatal).replaceAll('|', '\\|')} |` + : `| ${row.index} | ${row.filename} | ${row.projectId} | ${row.runId} | ${row.sandboxId} | ${row.exitCode} | ${row.terminal?.attempted ? (row.terminal.pass ? 'PASS' : 'FAIL') : 'N/A'} | ${row.pass ? 'PASS' : 'FAIL'} |`), + '', +].join('\n'), 'utf8'); +console.log(JSON.stringify(result, null, 2)); +if (totals.fail || totals.pass !== 10) process.exitCode = 1; diff --git a/go.mod b/go.mod deleted file mode 100644 index ac39c5a..0000000 --- a/go.mod +++ /dev/null @@ -1,23 +0,0 @@ -module agent-compose-ui - -go 1.26.2 - -require ( - github.com/labstack/echo/v4 v4.15.1 - github.com/samber/do/v2 v2.0.0 - golang.org/x/oauth2 v0.30.0 -) - -require ( - github.com/labstack/gommon v0.4.2 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/samber/go-type-to-string v1.8.0 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.14.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 8d98772..0000000 --- a/go.sum +++ /dev/null @@ -1,37 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs= -github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/samber/do/v2 v2.0.0 h1:tnunwWaoqSfJ9hxVIaJawIo7JXHQlqT9d9YBXlE9Keg= -github.com/samber/do/v2 v2.0.0/go.mod h1:ZSBCE7Xr6nTNIOVo4DBrkl2+ydUbIOzJjjdV8En5XO4= -github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= -github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.html b/index.html index 3f77223..382cb0a 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,13 @@ - + - - - agent-compose - + + + Agent Compose Console +
+ diff --git a/internal/app/server.go b/internal/app/server.go deleted file mode 100644 index a52db50..0000000 --- a/internal/app/server.go +++ /dev/null @@ -1,103 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "log/slog" - "net/http" - "net/url" - "os" - "time" - - "agent-compose-ui/internal/auth" - "agent-compose-ui/internal/config" - "agent-compose-ui/internal/proxy" - - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/samber/do/v2" -) - -func Run() error { - di := do.New() - Register(di) - - server := do.MustInvoke[*http.Server](di) - logger := do.MustInvoke[*slog.Logger](di) - authManager := do.MustInvoke[*auth.Manager](di) - backend := do.MustInvoke[*url.URL](di) - cfg := do.MustInvoke[config.Config](di) - logger.Info("agent-compose-ui server started", "listen", cfg.ListenAddr, "backend", backend.String(), "auth_enabled", authManager.Enabled(), "oauth_enabled", authManager.OAuthEnabled()) - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return fmt.Errorf("agent-compose-ui server failed: %w", err) - } - return nil -} - -func Register(di do.Injector) { - do.Provide(di, NewLogger) - do.Provide(di, NewConfig) - do.Provide(di, NewBackendURL) - do.Provide(di, NewEcho) - do.Provide(di, NewAuthManager) - do.Provide(di, NewBackendProxy) - do.Provide(di, NewHTTPServer) -} - -func NewLogger(di do.Injector) (*slog.Logger, error) { - logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) - slog.SetDefault(logger) - return logger, nil -} - -func NewConfig(di do.Injector) (config.Config, error) { - return config.LoadFromEnv(), nil -} - -func NewBackendURL(di do.Injector) (*url.URL, error) { - cfg := do.MustInvoke[config.Config](di) - backend, err := url.Parse(cfg.BackendURL) - if err != nil { - return nil, fmt.Errorf("parse backend URL: %w", err) - } - return backend, nil -} - -func NewEcho(di do.Injector) (*echo.Echo, error) { - app := echo.New() - app.HideBanner = true - app.HidePort = true - app.Use(middleware.RequestLogger()) - app.Use(middleware.Recover()) - registerRoutes(app, do.MustInvoke[*auth.Manager](di), do.MustInvoke[http.Handler](di)) - return app, nil -} - -func NewAuthManager(di do.Injector) (*auth.Manager, error) { - return auth.NewManagerFromEnv(), nil -} - -func NewBackendProxy(di do.Injector) (http.Handler, error) { - return proxy.NewBackendProxy(do.MustInvoke[*url.URL](di)), nil -} - -func NewHTTPServer(di do.Injector) (*http.Server, error) { - cfg := do.MustInvoke[config.Config](di) - return &http.Server{ - Addr: cfg.ListenAddr, - Handler: do.MustInvoke[*echo.Echo](di), - ReadHeaderTimeout: 15 * time.Second, - }, nil -} - -func registerRoutes(app *echo.Echo, authManager *auth.Manager, backend http.Handler) { - app.GET("/api/auth/status", authManager.HandleStatus) - app.HEAD("/api/auth/status", authManager.HandleStatus) - app.POST("/api/auth/login", authManager.HandleLogin) - app.POST("/api/auth/logout", authManager.HandleLogout) - app.GET("/oauth/authorize", authManager.HandleOAuthAuthorize) - app.HEAD("/oauth/authorize", authManager.HandleOAuthAuthorize) - app.GET("/oauth/callback", authManager.HandleOAuthCallback) - app.HEAD("/oauth/callback", authManager.HandleOAuthCallback) - app.Any("/*", authManager.Protect(echo.WrapHandler(backend))) -} diff --git a/internal/app/server_test.go b/internal/app/server_test.go deleted file mode 100644 index 2a2337f..0000000 --- a/internal/app/server_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package app - -import ( - "net/http" - "testing" - - "agent-compose-ui/internal/config" - - "github.com/samber/do/v2" -) - -func TestRegisterBuildsHTTPServer(t *testing.T) { - t.Setenv("AUTH_PASSWORD", "secret") - t.Setenv("AUTH_SECRET", "test-secret") - - di := do.New() - Register(di) - - server := do.MustInvoke[*http.Server](di) - if server.Addr != config.DefaultListenAddr { - t.Fatalf("server addr = %q, want %q", server.Addr, config.DefaultListenAddr) - } - if server.Handler == nil { - t.Fatal("server handler is nil") - } -} diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index 00def4b..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,153 +0,0 @@ -package auth - -import ( - "crypto/rand" - "fmt" - "log/slog" - "net/http" - "os" - "strings" - "time" - - "github.com/labstack/echo/v4" - "golang.org/x/oauth2" -) - -const ( - authCookieName = "agent_compose_auth" - oauthStateCookieName = "agent_compose_oauth_state" -) - -type Manager struct { - enabled bool - username string - password string - secret []byte - ttl time.Duration - oauthEnabled bool - oauthUser string - oauthUserInfo string - oauth2Config *oauth2.Config - oauthStateTTL time.Duration -} - -func NewManagerFromEnv() *Manager { - ttl := 24 * time.Hour - if raw := strings.TrimSpace(os.Getenv("AUTH_SESSION_TTL")); raw != "" { - if parsed, err := time.ParseDuration(raw); err != nil { - slog.Warn("failed to parse AUTH_SESSION_TTL", "value", raw, "error", err) - } else if parsed <= 0 { - slog.Warn("AUTH_SESSION_TTL must be positive", "value", raw) - } else { - ttl = parsed - } - } - username := strings.TrimSpace(os.Getenv("AUTH_USERNAME")) - if username == "" { - username = "admin" - } - manager := &Manager{ - enabled: os.Getenv("AUTH_PASSWORD") != "", - username: username, - password: os.Getenv("AUTH_PASSWORD"), - ttl: ttl, - oauthStateTTL: 5 * time.Minute, - } - manager.configureOAuth(username) - if !manager.enabled { - return manager - } - if secret := os.Getenv("AUTH_SECRET"); secret != "" { - manager.secret = []byte(secret) - return manager - } - manager.secret = make([]byte, 32) - if _, err := rand.Read(manager.secret); err != nil { - manager.secret = []byte(fmt.Sprintf("%d:%s", time.Now().UnixNano(), manager.password)) - } - slog.Warn("AUTH_SECRET is not set; auth sessions will be invalid after agent-compose-ui server restart") - return manager -} - -func (a *Manager) configureOAuth(username string) { - oauthScopes := splitAndTrimEnv(os.Getenv("OAUTH_SCOPES")) - if len(oauthScopes) == 0 { - oauthScopes = []string{"profile"} - } - oauthBaseURL := strings.TrimRight(os.Getenv("OAUTH_BASE_URL"), "/") - oauthAuthURL := firstNonEmpty(os.Getenv("OAUTH_AUTH_URL"), oauthBaseURL+"/oauth2/auth") - oauthTokenURL := firstNonEmpty(os.Getenv("OAUTH_TOKEN_URL"), oauthBaseURL+"/oauth2/token") - oauthUserInfoURL := firstNonEmpty(os.Getenv("OAUTH_USERINFO_URL"), oauthBaseURL+"/userinfo") - oauthCallbackURL := os.Getenv("OAUTH_CALLBACK_URL") - oauthAPIKey := os.Getenv("OAUTH_APIKEY") - a.oauthEnabled = oauthAPIKey != "" && oauthCallbackURL != "" && oauthAuthURL != "" && oauthTokenURL != "" - if !a.oauthEnabled { - return - } - a.enabled = true - a.oauthUser = username - if a.oauthUser == "" { - a.oauthUser = "oauth" - } - a.oauthUserInfo = oauthUserInfoURL - authStyle := oauth2.AuthStyleInParams - clientSecret := os.Getenv("OAUTH_SECRET") - switch strings.ToLower(strings.TrimSpace(os.Getenv("OAUTH_CLIENT_AUTH_METHOD"))) { - case "client_secret_basic": - authStyle = oauth2.AuthStyleInHeader - case "none": - clientSecret = "" - case "client_secret_post", "": - authStyle = oauth2.AuthStyleInParams - default: - slog.Warn("unsupported OAUTH_CLIENT_AUTH_METHOD; using client_secret_post", "value", os.Getenv("OAUTH_CLIENT_AUTH_METHOD")) - } - a.oauth2Config = &oauth2.Config{ - ClientID: oauthAPIKey, - ClientSecret: clientSecret, - RedirectURL: oauthCallbackURL, - Scopes: oauthScopes, - Endpoint: oauth2.Endpoint{ - AuthURL: oauthAuthURL, - TokenURL: oauthTokenURL, - AuthStyle: authStyle, - }, - } -} - -func (a *Manager) Protect(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - r := c.Request() - if !a.enabled || isPublicAuthPath(r.URL.Path) || isRuntimeLLMFacadeRequest(r) { - return next(c) - } - if !a.protectsPath(r.URL.Path, r.Header.Get("Accept")) { - return next(c) - } - if _, _, ok := a.validateRequest(r); ok { - return next(c) - } - if acceptsHTML(r) { - return c.Redirect(http.StatusFound, loginRedirectPath(r)) - } - return echoJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) - } -} - -func (a *Manager) Enabled() bool { - return a.enabled -} - -func (a *Manager) OAuthEnabled() bool { - return a.oauthEnabled -} - -func (a *Manager) protectsPath(path string, accept string) bool { - if strings.HasPrefix(path, "/agentcompose.v1.") || strings.HasPrefix(path, "/agentcompose.v2.") || strings.HasPrefix(path, "/health.v1.") || strings.HasPrefix(path, "/agent-compose/session/") { - return true - } - if strings.HasPrefix(path, "/api/") { - return true - } - return strings.Contains(accept, "text/html") -} diff --git a/internal/auth/auth_handlers.go b/internal/auth/auth_handlers.go deleted file mode 100644 index 79a043a..0000000 --- a/internal/auth/auth_handlers.go +++ /dev/null @@ -1,153 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/labstack/echo/v4" - "golang.org/x/oauth2" -) - -type authStatusResponse struct { - Enabled bool `json:"enabled"` - LoggedIn bool `json:"loggedIn"` - OAuthEnabled bool `json:"oauthEnabled"` - Username string `json:"username,omitempty"` - ExpiresAt string `json:"expiresAt,omitempty"` -} - -type loginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -func (a *Manager) HandleStatus(c echo.Context) error { - r := c.Request() - if r.Method != http.MethodGet && r.Method != http.MethodHead { - return c.NoContent(http.StatusMethodNotAllowed) - } - username, expiresAt, ok := a.validateRequest(r) - resp := authStatusResponse{Enabled: a.enabled, LoggedIn: !a.enabled || ok, OAuthEnabled: a.oauthEnabled} - if ok { - resp.Username = username - resp.ExpiresAt = expiresAt.UTC().Format(time.RFC3339) - } - return echoJSON(c, http.StatusOK, resp) -} - -func (a *Manager) HandleLogin(c echo.Context) error { - r := c.Request() - if r.Method != http.MethodPost { - return c.NoContent(http.StatusMethodNotAllowed) - } - if !a.enabled { - return echoJSON(c, http.StatusOK, authStatusResponse{Enabled: false, LoggedIn: true}) - } - if a.password == "" { - return echoJSON(c, http.StatusUnauthorized, map[string]string{"error": "password login is not configured"}) - } - var req loginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - return echoJSON(c, http.StatusBadRequest, map[string]string{"error": "invalid login request"}) - } - if !constantTimeEqual(req.Username, a.username) || !constantTimeEqual(req.Password, a.password) { - return echoJSON(c, http.StatusUnauthorized, map[string]string{"error": "invalid username or password"}) - } - expiresAt := time.Now().UTC().Add(a.ttl) - c.SetCookie(a.cookie(a.signedValue(a.username, expiresAt), expiresAt)) - return echoJSON(c, http.StatusOK, authStatusResponse{Enabled: true, LoggedIn: true, Username: a.username, ExpiresAt: expiresAt.Format(time.RFC3339)}) -} - -func (a *Manager) HandleLogout(c echo.Context) error { - c.SetCookie(a.cookie("", time.Unix(0, 0).UTC())) - return echoJSON(c, http.StatusOK, authStatusResponse{Enabled: a.enabled, LoggedIn: false}) -} - -func (a *Manager) HandleOAuthAuthorize(c echo.Context) error { - r := c.Request() - if r.Method != http.MethodGet && r.Method != http.MethodHead { - return c.NoContent(http.StatusMethodNotAllowed) - } - if !a.oauthEnabled || a.oauth2Config == nil { - return echoJSON(c, http.StatusNotFound, map[string]string{"error": "oauth is not configured"}) - } - state, err := generateOAuthState(16) - if err != nil { - return echoJSON(c, http.StatusInternalServerError, map[string]string{"error": "failed to start oauth login"}) - } - next := sanitizeOAuthNext(r.URL.Query().Get("next")) - c.SetCookie(a.oauthStateCookie(state, next, time.Now().UTC().Add(a.oauthStateTTL))) - authURL := a.oauth2Config.AuthCodeURL(state, oauth2.SetAuthURLParam("scope", strings.Join(a.oauth2Config.Scopes, " "))) - return c.Redirect(http.StatusFound, authURL) -} - -func (a *Manager) HandleOAuthCallback(c echo.Context) error { - r := c.Request() - if r.Method != http.MethodGet && r.Method != http.MethodHead { - return c.NoContent(http.StatusMethodNotAllowed) - } - if !a.oauthEnabled || a.oauth2Config == nil { - return echoJSON(c, http.StatusNotFound, map[string]string{"error": "oauth is not configured"}) - } - if authErr := r.URL.Query().Get("error"); authErr != "" { - return echoJSON(c, http.StatusBadRequest, map[string]string{"error": "oauth authorization failed: " + authErr}) - } - stateCookie, err := r.Cookie(oauthStateCookieName) - if err != nil || stateCookie.Value == "" { - return echoJSON(c, http.StatusBadRequest, map[string]string{"error": "oauth state cookie missing or expired"}) - } - c.SetCookie(a.oauthStateCookie("", "", time.Unix(0, 0).UTC())) - expectedState, next, ok := decodeOAuthStateCookie(stateCookie.Value) - if !ok || expectedState == "" || expectedState != r.URL.Query().Get("state") { - return echoJSON(c, http.StatusBadRequest, map[string]string{"error": "oauth state mismatch"}) - } - code := r.URL.Query().Get("code") - if code == "" { - return echoJSON(c, http.StatusBadRequest, map[string]string{"error": "authorization code is missing"}) - } - token, err := a.oauth2Config.Exchange(r.Context(), code) - if err != nil { - slog.Error("oauth token exchange failed", "error", err, "token_url", a.oauth2Config.Endpoint.TokenURL, "redirect_url", a.oauth2Config.RedirectURL) - return echoJSON(c, http.StatusBadGateway, map[string]string{"error": "failed to exchange authorization code"}) - } - username, err := a.fetchOAuthUsername(r, token) - if err != nil { - return echoJSON(c, http.StatusBadGateway, map[string]string{"error": "failed to retrieve oauth user"}) - } - if username == "" { - username = a.oauthUser - } - expiresAt := time.Now().UTC().Add(a.ttl) - c.SetCookie(a.cookie(a.signedValue(username, expiresAt), expiresAt)) - return c.Redirect(http.StatusFound, sanitizeOAuthNext(next)) -} - -func (a *Manager) fetchOAuthUsername(r *http.Request, token *oauth2.Token) (string, error) { - if a.oauthUserInfo == "" { - return a.oauthUser, nil - } - client := a.oauth2Config.Client(r.Context(), token) - resp, err := client.Get(a.oauthUserInfo) - if err != nil { - return "", fmt.Errorf("call userinfo endpoint: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode >= http.StatusBadRequest { - return "", fmt.Errorf("userinfo status %d", resp.StatusCode) - } - var info struct { - ID string `json:"id"` - Sub string `json:"sub"` - Username string `json:"username"` - Name string `json:"name"` - Email string `json:"email"` - } - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { - return "", fmt.Errorf("decode userinfo: %w", err) - } - return firstNonEmpty(info.Username, info.Email, info.Name, info.ID, info.Sub, a.oauthUser), nil -} diff --git a/internal/auth/main_test.go b/internal/auth/main_test.go deleted file mode 100644 index 25e9c6a..0000000 --- a/internal/auth/main_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package auth - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/labstack/echo/v4" -) - -func TestAuthStatusDisabled(t *testing.T) { - auth := NewManagerFromEnv() - handler := newTestApp(auth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status code = %d, want %d", rec.Code, http.StatusOK) - } - if body := rec.Body.String(); !strings.Contains(body, `"enabled":false`) || !strings.Contains(body, `"loggedIn":true`) { - t.Fatalf("unexpected auth status body: %s", body) - } -} - -func TestAuthProtectsRPCAndAcceptsBasicAuth(t *testing.T) { - t.Setenv("AUTH_USERNAME", "admin") - t.Setenv("AUTH_PASSWORD", "secret") - t.Setenv("AUTH_SECRET", "test-secret") - - auth := NewManagerFromEnv() - handler := newTestApp(auth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - - req := httptest.NewRequest(http.MethodPost, "/agentcompose.v1.AgentService/ListAgents", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("unauthenticated status = %d, want %d", rec.Code, http.StatusUnauthorized) - } - - req = httptest.NewRequest(http.MethodPost, "/agentcompose.v1.AgentService/ListAgents", nil) - req.SetBasicAuth("admin", "secret") - rec = httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusNoContent { - t.Fatalf("authenticated status = %d, want %d", rec.Code, http.StatusNoContent) - } -} - -func TestAuthAllowsWebhookIngress(t *testing.T) { - t.Setenv("AUTH_PASSWORD", "secret") - t.Setenv("AUTH_SECRET", "test-secret") - - auth := NewManagerFromEnv() - handler := newTestApp(auth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusAccepted) - })) - req := httptest.NewRequest(http.MethodPost, "/api/webhooks/github", nil) - rec := httptest.NewRecorder() - - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusAccepted { - t.Fatalf("webhook status = %d, want %d", rec.Code, http.StatusAccepted) - } -} - -func newTestApp(auth *Manager, backend http.Handler) http.Handler { - app := echo.New() - app.GET("/api/auth/status", auth.HandleStatus) - app.HEAD("/api/auth/status", auth.HandleStatus) - app.POST("/api/auth/login", auth.HandleLogin) - app.POST("/api/auth/logout", auth.HandleLogout) - app.GET("/oauth/authorize", auth.HandleOAuthAuthorize) - app.HEAD("/oauth/authorize", auth.HandleOAuthAuthorize) - app.GET("/oauth/callback", auth.HandleOAuthCallback) - app.HEAD("/oauth/callback", auth.HandleOAuthCallback) - app.Any("/*", auth.Protect(echo.WrapHandler(backend))) - return app -} diff --git a/internal/auth/paths.go b/internal/auth/paths.go deleted file mode 100644 index a9cde52..0000000 --- a/internal/auth/paths.go +++ /dev/null @@ -1,52 +0,0 @@ -package auth - -import ( - "net/http" - "net/url" - "strings" -) - -func isPublicAuthPath(path string) bool { - if strings.HasPrefix(path, "/api/webhooks/") { - return true - } - return path == "/login" || path == "/api/auth/status" || path == "/api/auth/login" || path == "/api/auth/logout" || - path == "/oauth/authorize" || path == "/oauth/callback" -} - -func isRuntimeLLMFacadeRequest(r *http.Request) bool { - if r.Method != http.MethodPost { - return false - } - path := strings.TrimRight(r.URL.Path, "/") - return strings.HasPrefix(path, "/api/runtime/sessions/") && - (strings.HasSuffix(path, "/llm/openai/v1/responses") || - strings.HasSuffix(path, "/llm/openai/v1/chat/completions") || - strings.HasSuffix(path, "/llm/anthropic/v1/messages")) -} - -func acceptsHTML(r *http.Request) bool { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - return false - } - return strings.Contains(r.Header.Get("Accept"), "text/html") -} - -func loginRedirectPath(r *http.Request) string { - next := r.URL.RequestURI() - if next == "" || isLoginPath(next) || strings.HasPrefix(next, "//") { - return "/login" - } - return "/login?next=" + url.QueryEscape(next) -} - -func sanitizeOAuthNext(next string) string { - if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || isLoginPath(next) { - return "/" - } - return next -} - -func isLoginPath(path string) bool { - return path == "/login" || strings.HasPrefix(path, "/login?") || strings.HasPrefix(path, "/login#") -} diff --git a/internal/auth/response.go b/internal/auth/response.go deleted file mode 100644 index 02a8495..0000000 --- a/internal/auth/response.go +++ /dev/null @@ -1,49 +0,0 @@ -package auth - -import ( - "encoding/json" - "errors" - "io" - "log/slog" - "net/http" - "strings" - - "github.com/labstack/echo/v4" -) - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(value); err != nil && !errors.Is(err, http.ErrHandlerTimeout) && !errors.Is(err, io.ErrClosedPipe) { - slog.Warn("failed to write JSON response", "error", err) - } -} - -func echoJSON(c echo.Context, status int, value any) error { - if err := c.JSON(status, value); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { - slog.Warn("failed to write JSON response", "error", err) - } - return nil -} - -func splitAndTrimEnv(value string) []string { - fields := strings.FieldsFunc(value, func(r rune) bool { - return r == ',' || r == ';' || r == '\n' - }) - items := make([]string, 0, len(fields)) - for _, field := range fields { - if trimmed := strings.TrimSpace(field); trimmed != "" { - items = append(items, trimmed) - } - } - return items -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} diff --git a/internal/auth/session.go b/internal/auth/session.go deleted file mode 100644 index 57e9f71..0000000 --- a/internal/auth/session.go +++ /dev/null @@ -1,115 +0,0 @@ -package auth - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "encoding/hex" - "net/http" - "strconv" - "strings" - "time" -) - -func (a *Manager) cookie(value string, expiresAt time.Time) *http.Cookie { - maxAge := int(time.Until(expiresAt).Seconds()) - if value == "" { - maxAge = -1 - } - return &http.Cookie{Name: authCookieName, Value: value, Path: "/", Expires: expiresAt, MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode} -} - -func (a *Manager) oauthStateCookie(state, next string, expiresAt time.Time) *http.Cookie { - maxAge := int(time.Until(expiresAt).Seconds()) - if state == "" { - maxAge = -1 - } - return &http.Cookie{Name: oauthStateCookieName, Value: encodeOAuthStateCookie(state, next), Path: "/", Expires: expiresAt, MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode} -} - -func (a *Manager) signedValue(username string, expiresAt time.Time) string { - expiry := strconv.FormatInt(expiresAt.Unix(), 10) - payload := username + "|" + expiry - mac := hmac.New(sha256.New, a.secret) - _, _ = mac.Write([]byte(payload)) - value := payload + "|" + hex.EncodeToString(mac.Sum(nil)) - return base64.RawURLEncoding.EncodeToString([]byte(value)) -} - -func (a *Manager) validateRequest(r *http.Request) (string, time.Time, bool) { - if !a.enabled { - return "", time.Time{}, true - } - if username, password, ok := r.BasicAuth(); ok && a.password != "" { - if constantTimeEqual(username, a.username) && constantTimeEqual(password, a.password) { - return a.username, time.Now().UTC().Add(a.ttl), true - } - return "", time.Time{}, false - } - cookie, err := r.Cookie(authCookieName) - if err != nil || cookie.Value == "" { - return "", time.Time{}, false - } - decoded, err := base64.RawURLEncoding.DecodeString(cookie.Value) - if err != nil { - return "", time.Time{}, false - } - parts := strings.Split(string(decoded), "|") - if len(parts) != 3 { - return "", time.Time{}, false - } - username, expiry, signature := parts[0], parts[1], parts[2] - expiresUnix, err := strconv.ParseInt(expiry, 10, 64) - if err != nil { - return "", time.Time{}, false - } - expiresAt := time.Unix(expiresUnix, 0).UTC() - if !time.Now().UTC().Before(expiresAt) { - return "", time.Time{}, false - } - if username != a.username && (!a.oauthEnabled || username == "") { - return "", time.Time{}, false - } - expected := a.signedValue(username, expiresAt) - if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(expected)) != 1 { - return "", time.Time{}, false - } - if _, err := hex.DecodeString(signature); err != nil { - return "", time.Time{}, false - } - return username, expiresAt, true -} - -func generateOAuthState(length int) (string, error) { - if length <= 0 { - length = 16 - } - buf := make([]byte, length) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return hex.EncodeToString(buf), nil -} - -func encodeOAuthStateCookie(state, next string) string { - payload := state + "|" + sanitizeOAuthNext(next) - return base64.RawURLEncoding.EncodeToString([]byte(payload)) -} - -func decodeOAuthStateCookie(value string) (string, string, bool) { - decoded, err := base64.RawURLEncoding.DecodeString(value) - if err != nil { - return "", "", false - } - parts := strings.SplitN(string(decoded), "|", 2) - if len(parts) != 2 { - return "", "", false - } - return parts[0], sanitizeOAuthNext(parts[1]), true -} - -func constantTimeEqual(left, right string) bool { - return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1 -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index cfa7e37..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,18 +0,0 @@ -package config - -const ( - DefaultListenAddr = "127.0.0.1:8080" - DefaultBackendURL = "http://agent-compose:7410" -) - -type Config struct { - ListenAddr string - BackendURL string -} - -func LoadFromEnv() Config { - return Config{ - ListenAddr: DefaultListenAddr, - BackendURL: DefaultBackendURL, - } -} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go deleted file mode 100644 index 7cd251c..0000000 --- a/internal/proxy/proxy.go +++ /dev/null @@ -1,38 +0,0 @@ -package proxy - -import ( - "log/slog" - "net" - "net/http" - "net/http/httputil" - "net/url" - "time" -) - -func NewBackendProxy(backend *url.URL) http.Handler { - proxy := httputil.NewSingleHostReverseProxy(backend) - originalDirector := proxy.Director - proxy.Director = func(req *http.Request) { - originalHost := req.Host - originalDirector(req) - if req.Header.Get("X-Forwarded-Host") == "" { - req.Header.Set("X-Forwarded-Host", originalHost) - } - } - proxy.Transport = &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, - ForceAttemptHTTP2: true, - MaxIdleConns: 256, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - } - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - slog.Error("proxy request failed", "path", r.URL.Path, "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadGateway) - _, _ = w.Write([]byte(`{"error":"failed to proxy daemon request"}` + "\n")) - } - return proxy -} diff --git a/nginx/10-normalize-jupyter-proxy-base.envsh b/nginx/10-normalize-jupyter-proxy-base.envsh deleted file mode 100644 index aae3223..0000000 --- a/nginx/10-normalize-jupyter-proxy-base.envsh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh - -raw="${JUPYTER_PROXY_BASE:-/jupyter}" -raw="$(printf '%s' "$raw" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - -case "$raw" in - "") raw="/jupyter" ;; - /*) ;; - *) raw="/$raw" ;; -esac - -while [ "$raw" != "/" ] && [ "${raw%/}" != "$raw" ]; do - raw="${raw%/}" -done - -if [ "$raw" = "/" ]; then - raw="/jupyter" -fi - -case "$raw" in - *[!A-Za-z0-9_./-]*) - echo "invalid JUPYTER_PROXY_BASE: $raw" >&2 - raw="/jupyter" - ;; -esac - -export NGINX_JUPYTER_PROXY_BASE="$raw" diff --git a/nginx/Dockerfile b/nginx/Dockerfile deleted file mode 100644 index f169edb..0000000 --- a/nginx/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -ARG REGISTRY_MIRROR=docker.io -ARG GO_VERSION=1.26.2 - -FROM ${REGISTRY_MIRROR}/library/node:25-bookworm AS frontend-build -ARG AGENT_COMPOSE_BASE=/ -WORKDIR /app -COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./ -RUN npm ci -COPY src ./src -RUN AGENT_COMPOSE_BASE=${AGENT_COMPOSE_BASE} npm run build:ui - -FROM ${REGISTRY_MIRROR}/library/golang:${GO_VERSION}-bookworm AS ui-server-build -WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download -COPY cmd ./cmd -COPY internal ./internal -RUN CGO_ENABLED=0 go build -o /out/agent-compose-ui-server ./cmd/agent-compose-ui-server - -FROM ${REGISTRY_MIRROR}/library/nginx:alpine -RUN rm -f /etc/nginx/conf.d/default.conf -COPY nginx/nginx.main /etc/nginx/nginx.conf -COPY nginx/10-normalize-jupyter-proxy-base.envsh /docker-entrypoint.d/10-normalize-jupyter-proxy-base.envsh -RUN chmod +x /docker-entrypoint.d/10-normalize-jupyter-proxy-base.envsh -COPY nginx/nginx.conf /etc/nginx/templates/agent-compose.conf.template -COPY --from=frontend-build /app/dist /srv/agent-compose/frontend -COPY --from=ui-server-build /out/agent-compose-ui-server /usr/local/bin/agent-compose-ui-server -COPY nginx/agent-compose-entrypoint.sh /agent-compose-entrypoint.sh -RUN chmod +x /agent-compose-entrypoint.sh -ENTRYPOINT ["/agent-compose-entrypoint.sh"] -CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx/agent-compose-entrypoint.sh b/nginx/agent-compose-entrypoint.sh deleted file mode 100644 index c850b2a..0000000 --- a/nginx/agent-compose-entrypoint.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/sh -set -eu - -/usr/local/bin/agent-compose-ui-server & -ui_server_pid=$! - -/docker-entrypoint.sh "$@" & -nginx_pid=$! - -cleanup() { - kill "$ui_server_pid" "$nginx_pid" 2>/dev/null || true - wait "$ui_server_pid" "$nginx_pid" 2>/dev/null || true -} - -trap cleanup INT TERM - -while :; do - if ! kill -0 "$ui_server_pid" 2>/dev/null; then - set +e - wait "$ui_server_pid" - status=$? - set -e - kill "$nginx_pid" 2>/dev/null || true - wait "$nginx_pid" 2>/dev/null || true - exit "$status" - fi - if ! kill -0 "$nginx_pid" 2>/dev/null; then - set +e - wait "$nginx_pid" - status=$? - set -e - kill "$ui_server_pid" 2>/dev/null || true - wait "$ui_server_pid" 2>/dev/null || true - exit "$status" - fi - sleep 1 -done diff --git a/nginx/nginx.conf b/nginx/nginx.conf deleted file mode 100644 index 5125efb..0000000 --- a/nginx/nginx.conf +++ /dev/null @@ -1,137 +0,0 @@ - -log_format agent_compose '$remote_addr - [$time_local] $status "$request" "$http_user_agent"'; - -upstream agent_compose_ui_server { - server 127.0.0.1:8080; - keepalive 256; -} - -server { - listen 8000 default_server; - server_name localhost _; - - access_log /var/log/nginx/agent-compose.access.log; - error_log /var/log/nginx/agent-compose.error.log; - - error_page 404 /404.html; - - charset utf-8; - root /srv/agent-compose/frontend; - - client_header_timeout 10s; - client_body_timeout 10s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - - client_max_body_size 1024m; - - location ^~ /agent-compose/session/ { - proxy_pass http://agent_compose_ui_server; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - proxy_buffering off; - - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - - access_log /var/log/nginx/agent-compose.jupyter.log agent_compose; - } - - location ^~ ${NGINX_JUPYTER_PROXY_BASE}/ { - proxy_pass http://agent_compose_ui_server; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - proxy_buffering off; - - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - - access_log /var/log/nginx/agent-compose.jupyter.log agent_compose; - } - - location ^~ /agentcompose.v1. { - proxy_pass http://agent_compose_ui_server; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - access_log /var/log/nginx/agent-compose.api.log agent_compose; - } - - location ^~ /agentcompose.v2. { - proxy_pass http://agent_compose_ui_server; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - access_log /var/log/nginx/agent-compose.api.log agent_compose; - } - - location ^~ /health.v1. { - proxy_pass http://agent_compose_ui_server; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - access_log /var/log/nginx/agent-compose.api.log agent_compose; - } - - location ^~ /api/ { - proxy_pass http://agent_compose_ui_server; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - access_log /var/log/nginx/agent-compose.api.log agent_compose; - } - - location ^~ /oauth/ { - proxy_pass http://agent_compose_ui_server; - proxy_http_version 1.1; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - proxy_connect_timeout 300s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - send_timeout 300s; - access_log /var/log/nginx/agent-compose.api.log agent_compose; - } - - location = / { - try_files $uri /index.html; - } - - location = /404.html { - return 404; - } - - location / { - try_files $uri $uri/ /index.html; - } -} diff --git a/nginx/nginx.main b/nginx/nginx.main deleted file mode 100644 index d29c2c9..0000000 --- a/nginx/nginx.main +++ /dev/null @@ -1,33 +0,0 @@ - -user nginx; -worker_processes 2; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - - -events { - worker_connections 1024; -} - - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - #tcp_nopush on; - - keepalive_timeout 65; - - #gzip on; - - include /etc/nginx/conf.d/*.conf; -} - diff --git a/package-lock.json b/package-lock.json index b2b7896..4742569 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,35 +1,278 @@ { - "name": "agent-compose-ui", + "name": "agent-compose-console", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "agent-compose-ui", - "license": "AGPL-3.0", + "name": "agent-compose-console", + "version": "0.1.0", "dependencies": { - "@ant-design/icons-svg": "^4.4.2", - "@bufbuild/protobuf": "^1.10.1", + "@bufbuild/protobuf": "^1.10.0", "@connectrpc/connect": "^1.7.0", "@connectrpc/connect-web": "^1.7.0", - "@xterm/addon-fit": "^0.11.0", - "svelte": "^5.38.10", - "xterm": "^5.3.0" + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "js-yaml": "^4.1.0", + "monaco-editor": "^0.52.0" }, "devDependencies": { - "@bufbuild/protoc-gen-es": "^1.10.1", + "@bufbuild/buf": "^1.50.0", + "@bufbuild/protoc-gen-es": "^1.10.0", "@connectrpc/protoc-gen-connect-es": "^1.7.0", - "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@types/node": "^26.1.1", - "typescript": "^5.9.3", - "vite": "^7.1.9" + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.4.2", + "@tsconfig/svelte": "^5.0.0", + "@types/js-yaml": "^4.0.9", + "@vitest/coverage-v8": "^4.1.10", + "happy-dom": "^20.10.6", + "playwright": "^1.61.1", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^7.0.0", + "vite-plugin-monaco-editor": "^1.1.0", + "vitest": "^4.1.10" } }, - "node_modules/@ant-design/icons-svg": { + "node_modules/@adobe/css-tools": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", - "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, "license": "MIT" }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bufbuild/buf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.71.0.tgz", + "integrity": "sha512-GDcjBCwLgHT/4nX4YSnYatZ7sDZDpHV6dxQvoT2/P6gKvV23O6hl8NryzLIRKmeau0FRXpQKHVy1dMfnBSpy+w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.71.0", + "@bufbuild/buf-darwin-x64": "1.71.0", + "@bufbuild/buf-linux-aarch64": "1.71.0", + "@bufbuild/buf-linux-armv7": "1.71.0", + "@bufbuild/buf-linux-x64": "1.71.0", + "@bufbuild/buf-win32-arm64": "1.71.0", + "@bufbuild/buf-win32-x64": "1.71.0" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-qZ7xZQyen/jOKFPVs3dlN9pMA56PI4YEo3r4/9ixtiH9gyFgfowR31axsocUgXGThjiN8mvOA8WfpG2tvaSvsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.71.0.tgz", + "integrity": "sha512-2w95pc3X+z06/J66i6uNzA8QPuVOpbPrwyb6tkK0AcJFNvKPVYr4BxVC2koyImrQ3rxY1n9q8qviWMjSvq9fOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.71.0.tgz", + "integrity": "sha512-dwxErryMI3MRwtP/IgfdrqEjiAmVpttGhmO3xihiJIV2EAXt9J5yjzHhEDvnSgQ6nmNjEvO5QczcIaQjZEwF6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.71.0.tgz", + "integrity": "sha512-pfc+Qexm5C59VeRUjVmEvxkCXT5QbMR1R/CUtcSlk+spOFVwna0bSpkqIsky3kkHfzxiNSOsz3iki9/pAVX+CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.71.0.tgz", + "integrity": "sha512-Y7jLxr3wpMkpQSqZU/MrDmDSCkF4GxvhIL7wnNdSRpkhYAY6TPRHN+5nNgV7jp6mQ0zQSYh0MGxBeMgt/UVdmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.71.0.tgz", + "integrity": "sha512-UrxtD99zLE1qImtQC/W3a9cuj0/kB53B1bK38kmCMRFow939FhdZtqTRjbnZWauRi/pzAsjDyPCvnTa2XKT8Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.71.0.tgz", + "integrity": "sha512-+npiOimJ7ggeLul3KFwSlOjZnAZYwt3el64dJ3nJQMnui0avyvsRmU02o1bZI5yUnBvhcnTWdEbfRXUnkkVtgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@bufbuild/protobuf": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", @@ -581,6 +824,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -591,6 +835,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -601,6 +846,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -610,12 +856,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -714,6 +962,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -728,6 +979,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -742,6 +996,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -756,6 +1013,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -770,6 +1030,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -784,6 +1047,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -798,6 +1064,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -812,6 +1081,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -826,6 +1098,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -840,6 +1115,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -854,6 +1132,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -868,6 +1149,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -882,6 +1166,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -972,15 +1259,33 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" } }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "6.2.4", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", @@ -1020,10 +1325,147 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.1.3" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -1040,8 +1482,26 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/vfs": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", @@ -1055,16 +1515,179 @@ "typescript": "*" } }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", - "license": "MIT" + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", + "integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1073,33 +1696,147 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1128,10 +1865,48 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, "license": "MIT" }, "node_modules/esbuild": { @@ -1180,12 +1955,14 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.12", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.12.tgz", - "integrity": "sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==", + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1199,6 +1976,26 @@ } } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1218,9 +2015,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1232,30 +2029,211 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/happy-dom": { + "version": "20.10.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz", + "integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, "license": "MIT" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1264,9 +2242,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -1283,9 +2261,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -1296,6 +2274,13 @@ "node": ">=12.20.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1304,9 +2289,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1316,10 +2301,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -1345,6 +2358,56 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1390,6 +2453,39 @@ "fsevents": "~2.3.2" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1400,10 +2496,51 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/svelte": { - "version": "5.56.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", - "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "version": "5.56.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.6.tgz", + "integrity": "sha512-p4HDLDogGHKRKCrgckQHNs5PEfXkju6JI5jTywueaKJI5hAdjPohEhRtQ0M1SWC/+TA73SPln+r7srr+7e4nZA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -1427,6 +2564,58 @@ "node": ">=18" } }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1444,6 +2633,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1540,6 +2739,31 @@ } } }, + "node_modules/vite-plugin-monaco-editor": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz", + "integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "monaco-editor": ">=0.33.0" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -1560,17 +2784,150 @@ } } }, - "node_modules/xterm": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", - "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", - "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", - "license": "MIT" + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, "license": "MIT" } } diff --git a/package.json b/package.json index c01acba..1b3780c 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,49 @@ { - "name": "agent-compose-ui", + "name": "agent-compose-console", "private": true, - "license": "AGPL-3.0", + "version": "0.1.0", "type": "module", "scripts": { - "build:ui": "vite build", - "dev:ui": "vite", - "gen:api": "rm -rf src/gen && mkdir -p src/gen && protoc --plugin=./node_modules/.bin/protoc-gen-es --plugin=./node_modules/.bin/protoc-gen-connect-es -I ../agent-compose/proto --es_out src/gen --es_opt target=ts,import_extension=.js --connect-es_out src/gen --connect-es_opt target=ts,import_extension=.js health/v1/health.proto agentcompose/v2/agentcompose.proto && node scripts/normalize-generated-eof.mjs src/gen", - "check:ui": "vite build", - "check:server": "go test ./...", - "build:image": "docker build -f nginx/Dockerfile -t agent-compose-ui:local ." + "dev": "bun run scripts/dev.mjs", + "dev:web": "vite --host 0.0.0.0 --port 5174", + "dev:scripts": "bun run script-service/server.mjs", + "test": "bun test .test.js .test.mjs", + "test:component": "vitest run", + "test:all": "bun run check && bun run test && bun run test:e2e:unit && vitest run", + "test:e2e:unit": "bun test e2e/real-data/api.test.ts e2e/real-data/report.test.ts", + "test:e2e:real": "bun run e2e/real-data/run.ts", + "test:e2e:real:api": "bun run e2e/real-data/run.ts --api-only", + "build": "vite build", + "check": "svelte-check --tsconfig ./tsconfig.json", + "gen": "buf generate" + }, + "devDependencies": { + "@bufbuild/buf": "^1.50.0", + "@bufbuild/protoc-gen-es": "^1.10.0", + "@connectrpc/protoc-gen-connect-es": "^1.7.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.4.2", + "@tsconfig/svelte": "^5.0.0", + "@types/js-yaml": "^4.0.9", + "@vitest/coverage-v8": "^4.1.10", + "happy-dom": "^20.10.6", + "playwright": "^1.61.1", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^7.0.0", + "vite-plugin-monaco-editor": "^1.1.0", + "vitest": "^4.1.10" }, "dependencies": { - "@ant-design/icons-svg": "^4.4.2", - "@bufbuild/protobuf": "^1.10.1", + "@bufbuild/protobuf": "^1.10.0", "@connectrpc/connect": "^1.7.0", "@connectrpc/connect-web": "^1.7.0", - "@xterm/addon-fit": "^0.11.0", - "svelte": "^5.38.10", - "xterm": "^5.3.0" - }, - "devDependencies": { - "@bufbuild/protoc-gen-es": "^1.10.1", - "@connectrpc/protoc-gen-connect-es": "^1.7.0", - "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@types/node": "^26.1.1", - "typescript": "^5.9.3", - "vite": "^7.1.9" + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "js-yaml": "^4.1.0", + "monaco-editor": "^0.52.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..3ea47cd --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'playwright/test'; + +export default defineConfig({ + timeout: 120_000, + expect: { timeout: 30_000 }, + use: { + baseURL: process.env.AGENT_COMPOSE_E2E_FRONTEND_URL ?? 'http://127.0.0.1:5174', + headless: true, + locale: 'zh-CN', + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, +}); diff --git a/proto/agentcompose/v2/agentcompose.proto b/proto/agentcompose/v2/agentcompose.proto new file mode 100644 index 0000000..3acb3d0 --- /dev/null +++ b/proto/agentcompose/v2/agentcompose.proto @@ -0,0 +1,1713 @@ +syntax = "proto3"; + +package agentcompose.v2; + +import "google/protobuf/timestamp.proto"; + +option go_package = "agent-compose/proto/agentcompose/v2;agentcomposev2"; + +service ProjectService { + rpc ValidateProject(ValidateProjectRequest) returns (ValidateProjectResponse); + rpc ApplyProject(ApplyProjectRequest) returns (ApplyProjectResponse); + rpc GetProject(GetProjectRequest) returns (GetProjectResponse); + rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse); + rpc RemoveProject(RemoveProjectRequest) returns (RemoveProjectResponse); + rpc WatchProject(WatchProjectRequest) returns (stream WatchProjectResponse); + rpc GetScheduler(GetSchedulerRequest) returns (GetSchedulerResponse); + rpc ListSchedulers(ListSchedulersRequest) returns (ListSchedulersResponse); + rpc ListSchedulerEvents(ListSchedulerEventsRequest) returns (ListSchedulerEventsResponse); + rpc RunScheduler(RunSchedulerRequest) returns (RunSchedulerResponse); + rpc StartSchedulerRun(StartSchedulerRunRequest) returns (StartSchedulerRunResponse); + rpc GetSchedulerRun(GetSchedulerRunRequest) returns (GetSchedulerRunResponse); + rpc ListSchedulerRuns(ListSchedulerRunsRequest) returns (ListSchedulerRunsResponse); + rpc StopSchedulerRun(StopSchedulerRunRequest) returns (StopSchedulerRunResponse); + rpc SetSchedulerEnabled(SetSchedulerEnabledRequest) returns (SetSchedulerEnabledResponse); + rpc SetSchedulerTriggerEnabled(SetSchedulerTriggerEnabledRequest) returns (SetSchedulerTriggerEnabledResponse); +} + +service RunService { + rpc RunAgent(RunAgentRequest) returns (RunAgentResponse); + rpc StartRun(StartRunRequest) returns (StartRunResponse); + // Stable server-stream projection of RunAgent for non-interactive stream views. + // Interactive clients that need stdin, resize, signal, or multi-turn prompt + // attachment should use RunAttach. + rpc RunAgentStream(RunAgentRequest) returns (stream RunAgentStreamResponse); + rpc RunAttach(stream RunAttachRequest) returns (stream RunAttachResponse); + rpc GetRun(GetRunRequest) returns (GetRunResponse); + rpc ListRuns(ListRunsRequest) returns (ListRunsResponse); + rpc FollowRunLogs(FollowRunLogsRequest) returns (stream RunLogChunk); + rpc StopRun(StopRunRequest) returns (StopRunResponse); + rpc ListRunEvents(ListRunEventsRequest) returns (ListRunEventsResponse); + rpc ListSandboxRunEvents(ListSandboxRunEventsRequest) returns (ListSandboxRunEventsResponse); +} + +service ExecService { + rpc Exec(ExecRequest) returns (ExecResponse); + // Stable server-stream projection of Exec for non-interactive stream views. + // Interactive clients that need stdin, resize, or signal attachment should use + // ExecAttach. + rpc ExecStream(ExecRequest) returns (stream ExecStreamResponse); + rpc ExecAttach(stream ExecAttachRequest) returns (stream ExecAttachResponse); +} + +service ImageService { + rpc ListImages(ListImagesRequest) returns (ListImagesResponse); + rpc PullImage(PullImageRequest) returns (PullImageResponse); + rpc InspectImage(InspectImageRequest) returns (InspectImageResponse); + rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse); + rpc BuildImage(BuildImageRequest) returns (stream BuildImageEvent); +} + +service CacheService { + rpc ListCaches(ListCachesRequest) returns (ListCachesResponse); + rpc InspectCache(InspectCacheRequest) returns (InspectCacheResponse); + rpc PruneCaches(PruneCachesRequest) returns (PruneCachesResponse); + rpc RemoveCache(RemoveCacheRequest) returns (RemoveCacheResponse); +} + +service VolumeService { + rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse); + rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse); + rpc InspectVolume(InspectVolumeRequest) returns (InspectVolumeResponse); + rpc RemoveVolume(RemoveVolumeRequest) returns (RemoveVolumeResponse); + rpc PruneVolumes(PruneVolumesRequest) returns (PruneVolumesResponse); +} + +service SandboxService { + rpc RemoveSandbox(RemoveSandboxRequest) returns (RemoveSandboxResponse); + rpc GetSandboxStats(GetSandboxStatsRequest) returns (GetSandboxStatsResponse); + rpc GetSandbox(GetSandboxRequest) returns (GetSandboxResponse); + rpc StopSandbox(StopSandboxRequest) returns (StopSandboxResponse); + rpc ResumeSandbox(ResumeSandboxRequest) returns (ResumeSandboxResponse); + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + rpc ListSandboxHistory(ListSandboxHistoryRequest) returns (ListSandboxHistoryResponse); + rpc WatchSandbox(WatchSandboxRequest) returns (stream WatchSandboxResponse); +} + +service DashboardService { + rpc GetDashboardOverview(GetDashboardOverviewRequest) returns (GetDashboardOverviewResponse); + rpc WatchDashboardOverview(WatchDashboardOverviewRequest) returns (stream WatchDashboardOverviewResponse); +} + +service SettingsService { + rpc GetGlobalEnv(GetGlobalEnvRequest) returns (GetGlobalEnvResponse); + rpc UpdateGlobalEnv(UpdateGlobalEnvRequest) returns (UpdateGlobalEnvResponse); + rpc GetCapabilityGatewayConfig(GetCapabilityGatewayConfigRequest) returns (GetCapabilityGatewayConfigResponse); + rpc UpdateCapabilityGatewayConfig(UpdateCapabilityGatewayConfigRequest) returns (UpdateCapabilityGatewayConfigResponse); + rpc ListWorkspacePresets(ListWorkspacePresetsRequest) returns (ListWorkspacePresetsResponse); + rpc CreateWorkspacePreset(CreateWorkspacePresetRequest) returns (WorkspacePresetResponse); + rpc UpdateWorkspacePreset(UpdateWorkspacePresetRequest) returns (WorkspacePresetResponse); + rpc DeleteWorkspacePreset(DeleteWorkspacePresetRequest) returns (DeleteWorkspacePresetResponse); +} + +service CapabilityService { + rpc GetCapabilityStatus(GetCapabilityStatusRequest) returns (CapabilityStatusResponse); + rpc ListCapabilitySets(ListCapabilitySetsRequest) returns (ListCapabilitySetsResponse); + rpc GetCapabilityCatalog(GetCapabilityCatalogRequest) returns (GetCapabilityCatalogResponse); +} + +enum ProjectValidationSeverity { + PROJECT_VALIDATION_SEVERITY_UNSPECIFIED = 0; + PROJECT_VALIDATION_SEVERITY_WARNING = 1; + PROJECT_VALIDATION_SEVERITY_ERROR = 2; +} + +enum ProjectChangeAction { + PROJECT_CHANGE_ACTION_UNSPECIFIED = 0; + PROJECT_CHANGE_ACTION_CREATED = 1; + PROJECT_CHANGE_ACTION_UPDATED = 2; + PROJECT_CHANGE_ACTION_REMOVED = 3; + PROJECT_CHANGE_ACTION_UNCHANGED = 4; +} + +enum ProjectWatchEventType { + PROJECT_WATCH_EVENT_TYPE_UNSPECIFIED = 0; + PROJECT_WATCH_EVENT_TYPE_APPLIED = 1; + PROJECT_WATCH_EVENT_TYPE_REMOVED = 2; + PROJECT_WATCH_EVENT_TYPE_CHANGED = 3; +} + +enum RunStatus { + RUN_STATUS_UNSPECIFIED = 0; + RUN_STATUS_PENDING = 1; + RUN_STATUS_RUNNING = 2; + RUN_STATUS_SUCCEEDED = 3; + RUN_STATUS_FAILED = 4; + RUN_STATUS_CANCELED = 5; +} + +enum RunSource { + RUN_SOURCE_UNSPECIFIED = 0; + RUN_SOURCE_MANUAL = 1; + RUN_SOURCE_SCHEDULER = 2; + RUN_SOURCE_API = 3; +} + +enum SchedulerRunStatus { + SCHEDULER_RUN_STATUS_UNSPECIFIED = 0; + SCHEDULER_RUN_STATUS_RUNNING = 1; + SCHEDULER_RUN_STATUS_SUCCEEDED = 2; + SCHEDULER_RUN_STATUS_FAILED = 3; + SCHEDULER_RUN_STATUS_CANCELED = 4; + SCHEDULER_RUN_STATUS_SKIPPED = 5; +} + +enum AgentStatus { + AGENT_STATUS_UNSPECIFIED = 0; + AGENT_STATUS_ENABLED = 1; + AGENT_STATUS_DISABLED = 2; +} + +enum ProjectAgentAvailability { + PROJECT_AGENT_AVAILABILITY_UNSPECIFIED = 0; + PROJECT_AGENT_AVAILABILITY_AVAILABLE = 1; + PROJECT_AGENT_AVAILABILITY_UNAVAILABLE = 2; + PROJECT_AGENT_AVAILABILITY_VALIDATION_FAILED = 3; +} + +enum ProjectAgentHealth { + PROJECT_AGENT_HEALTH_UNSPECIFIED = 0; + PROJECT_AGENT_HEALTH_HEALTHY = 1; + PROJECT_AGENT_HEALTH_AT_RISK = 2; +} + +enum RunEventKind { + RUN_EVENT_KIND_UNSPECIFIED = 0; + RUN_EVENT_KIND_USER_MESSAGE = 1; + RUN_EVENT_KIND_AGENT_MESSAGE = 2; + RUN_EVENT_KIND_AGENT_ACTIVITY = 3; + RUN_EVENT_KIND_STATUS = 4; +} + +enum RunAgentStreamEventType { + RUN_AGENT_STREAM_EVENT_TYPE_UNSPECIFIED = 0; + RUN_AGENT_STREAM_EVENT_TYPE_STARTED = 1; + RUN_AGENT_STREAM_EVENT_TYPE_OUTPUT = 2; + RUN_AGENT_STREAM_EVENT_TYPE_STATUS = 3; + RUN_AGENT_STREAM_EVENT_TYPE_COMPLETED = 4; +} + +enum RunSandboxCleanupPolicy { + reserved "RUN_SESSION_CLEANUP_POLICY_UNSPECIFIED"; + reserved "RUN_SESSION_CLEANUP_POLICY_STOP_ON_COMPLETION"; + reserved "RUN_SESSION_CLEANUP_POLICY_KEEP_RUNNING"; + reserved "RUN_SESSION_CLEANUP_POLICY_REMOVE_ON_COMPLETION"; + + RUN_SANDBOX_CLEANUP_POLICY_UNSPECIFIED = 0; + RUN_SANDBOX_CLEANUP_POLICY_STOP_ON_COMPLETION = 1; + RUN_SANDBOX_CLEANUP_POLICY_KEEP_RUNNING = 2; + RUN_SANDBOX_CLEANUP_POLICY_REMOVE_ON_COMPLETION = 3; +} + +enum ExecStreamEventType { + EXEC_STREAM_EVENT_TYPE_UNSPECIFIED = 0; + EXEC_STREAM_EVENT_TYPE_STARTED = 1; + EXEC_STREAM_EVENT_TYPE_OUTPUT = 2; + EXEC_STREAM_EVENT_TYPE_COMPLETED = 3; +} + +enum AttachRunMode { + ATTACH_RUN_MODE_UNSPECIFIED = 0; + ATTACH_RUN_MODE_COMMAND = 1; + ATTACH_RUN_MODE_PROMPT = 2; +} + +enum StdioStream { + STDIO_STREAM_UNSPECIFIED = 0; + STDIO_STREAM_STDOUT = 1; + STDIO_STREAM_STDERR = 2; +} + +enum ImageStoreKind { + IMAGE_STORE_KIND_UNSPECIFIED = 0; + IMAGE_STORE_KIND_DOCKER_DAEMON = 1; + IMAGE_STORE_KIND_OCI_CACHE = 2; +} + +enum ImageAvailabilityStatus { + IMAGE_AVAILABILITY_STATUS_UNSPECIFIED = 0; + IMAGE_AVAILABILITY_STATUS_AVAILABLE = 1; + IMAGE_AVAILABILITY_STATUS_MISSING = 2; + IMAGE_AVAILABILITY_STATUS_ERROR = 3; +} + +enum ImageOperationStatus { + IMAGE_OPERATION_STATUS_UNSPECIFIED = 0; + IMAGE_OPERATION_STATUS_SUCCEEDED = 1; + IMAGE_OPERATION_STATUS_FAILED = 2; + IMAGE_OPERATION_STATUS_RUNNING = 3; +} + +enum MetricStatus { + METRIC_STATUS_UNSPECIFIED = 0; + METRIC_STATUS_OK = 1; + METRIC_STATUS_UNKNOWN = 2; + METRIC_STATUS_UNAVAILABLE = 3; +} + +enum CacheDomain { + reserved "CACHE_DOMAIN_SESSION_EPHEMERAL_STATE"; + + CACHE_DOMAIN_UNSPECIFIED = 0; + CACHE_DOMAIN_OCI_IMAGE_STORE = 1; + CACHE_DOMAIN_MATERIALIZED_IMAGE_CACHE = 2; + CACHE_DOMAIN_RUNTIME_DERIVED_CACHE = 3; + CACHE_DOMAIN_SANDBOX_EPHEMERAL_STATE = 4; +} + +enum CacheStatus { + CACHE_STATUS_UNSPECIFIED = 0; + CACHE_STATUS_ACTIVE = 1; + CACHE_STATUS_REFERENCED = 2; + CACHE_STATUS_UNUSED = 3; + CACHE_STATUS_EXPIRED = 4; + CACHE_STATUS_ORPHANED = 5; + CACHE_STATUS_UNKNOWN = 6; +} + +message ValidateProjectRequest { + ProjectSpec spec = 1; + ProjectSource source = 2; + string expected_spec_hash = 3; +} + +message ValidateProjectResponse { + bool valid = 1; + repeated ProjectValidationIssue issues = 2; + string spec_hash = 3; +} + +message ApplyProjectRequest { + ProjectSpec spec = 1; + ProjectSource source = 2; + string expected_spec_hash = 3; + bool dry_run = 4; +} + +message ApplyProjectResponse { + Project project = 1; + ProjectRevision revision = 2; + repeated ProjectChange changes = 3; + repeated ProjectValidationIssue issues = 4; + bool applied = 5; + bool unchanged = 6; +} + +message GetProjectRequest { + ProjectRef project = 1; + bool include_spec = 2; +} + +message GetProjectResponse { + Project project = 1; +} + +message ListProjectsRequest { + string query = 1; + bool include_removed = 2; + uint32 offset = 3; + uint32 limit = 4; +} + +message ListProjectsResponse { + repeated ProjectSummary projects = 1; + uint32 total_count = 2; + bool has_more = 3; + uint32 next_offset = 4; +} + +message RemoveProjectRequest { + reserved "stop_running_sessions"; + + ProjectRef project = 1; + bool remove_history = 2; + bool stop_running_sandboxes = 3; +} + +message RemoveProjectResponse { + Project project = 1; + repeated ProjectChange changes = 2; +} + +message WatchProjectRequest { + ProjectRef project = 1; +} + +message WatchProjectResponse { + ProjectWatchEventType type = 1; + Project project = 2; + ProjectRevision revision = 3; + repeated ProjectChange changes = 4; +} + +message ProjectRef { + string project_id = 1; + string name = 2; + string source_path = 3; +} + +message ProjectSource { + string compose_path = 1; + string project_dir = 2; +} + +message Project { + ProjectSummary summary = 1; + ProjectSpec spec = 2; + repeated ProjectAgent agents = 3; + repeated ProjectScheduler schedulers = 4; +} + +message ProjectSummary { + string project_id = 1; + string name = 2; + string source_path = 3; + uint64 current_revision = 4; + string spec_hash = 5; + uint32 agent_count = 6; + uint32 scheduler_count = 7; + uint32 running_run_count = 8; + string latest_run_id = 9; + string created_at = 10; + string updated_at = 11; + string removed_at = 12; +} + +message ProjectRevision { + string project_id = 1; + uint64 revision = 2; + string spec_hash = 3; + ProjectSpec spec = 4; + string created_at = 5; +} + +message ProjectAgent { + string project_id = 1; + string agent_name = 2; + string managed_agent_id = 3; + string provider = 4; + string model = 5; + string image = 6; + string driver = 7; + bool scheduler_enabled = 8; + bool enabled = 9; + ProjectAgentAvailability availability = 10; + ProjectAgentHealth health = 11; + ProjectAgentCurrentRun current_run = 12; + ProjectAgentLatestRun latest_run = 13; +} + +message ProjectAgentCurrentRun { + string text = 1; + uint32 running_run_count = 2; + uint32 running_scheduler_run_count = 3; +} + +message ProjectAgentLatestRun { + string run_id = 1; + RunStatus status = 2; + RunSource source = 3; + google.protobuf.Timestamp at = 4; +} + +message ProjectScheduler { + reserved 4; + reserved "managed_loader_id"; + + string project_id = 1; + string agent_name = 2; + string scheduler_id = 3; + bool enabled = 5; + uint32 trigger_count = 6; +} + +message GetSchedulerRequest { + ProjectRef project = 1; + string agent_name = 2; +} + +message GetSchedulerResponse { + ProjectScheduler scheduler = 1; + SchedulerSpec spec = 2; + repeated ResolvedTrigger triggers = 3; + bool overridden = 4; +} + +message ResolvedTrigger { + TriggerSpec spec = 1; + string trigger_id = 2; + bool enabled = 3; + bool overridden = 4; + google.protobuf.Timestamp next_fire_at = 5; + google.protobuf.Timestamp last_fired_at = 6; +} + +message ListSchedulersRequest { + string query = 1; + uint32 limit = 2; + string cursor = 3; +} + +message SchedulerSummary { + string project_id = 1; + string agent_name = 2; + string scheduler_id = 3; + bool enabled = 4; + uint32 trigger_count = 5; + uint32 run_count = 6; + google.protobuf.Timestamp latest_run_at = 7; + string last_error = 8; +} + +message ListSchedulersResponse { + repeated SchedulerSummary schedulers = 1; + string next_cursor = 2; +} + +message ListSchedulerEventsRequest { + ProjectRef project = 1; + string agent_name = 2; + uint32 limit = 3; + string cursor = 4; +} + +message SchedulerEvent { + string id = 1; + string type = 2; + string level = 3; + string message = 4; + string payload_json = 5; + string run_id = 6; + string trigger_id = 7; + google.protobuf.Timestamp created_at = 8; +} + +message ListSchedulerEventsResponse { + repeated SchedulerEvent events = 1; + string next_cursor = 2; +} + +message RunSchedulerRequest { + ProjectRef project = 1; + string agent_name = 2; + string trigger_id = 3; + string payload_json = 4; +} + +message RunSchedulerResponse { + SchedulerRun run = 1; +} + +message StartSchedulerRunRequest { + ProjectRef project = 1; + string agent_name = 2; + string trigger_id = 3; + string payload_json = 4; +} + +message StartSchedulerRunResponse { + SchedulerRun run = 1; +} + +message GetSchedulerRunRequest { + ProjectRef project = 1; + string run_id = 2; +} + +message GetSchedulerRunResponse { + SchedulerRun run = 1; +} + +message ListSchedulerRunsRequest { + ProjectRef project = 1; + string agent_name = 2; + uint32 limit = 3; + string cursor = 4; +} + +message ListSchedulerRunsResponse { + repeated SchedulerRun runs = 1; + string next_cursor = 2; +} + +message StopSchedulerRunRequest { + ProjectRef project = 1; + string run_id = 2; + string reason = 3; +} + +message StopSchedulerRunResponse { + SchedulerRun run = 1; + bool stop_requested = 2; +} + +message SchedulerRun { + string run_id = 1; + string project_id = 2; + string agent_name = 3; + string scheduler_id = 4; + string trigger_id = 5; + string trigger_kind = 6; + string trigger_source = 7; + SchedulerRunStatus status = 8; + google.protobuf.Timestamp started_at = 9; + google.protobuf.Timestamp completed_at = 10; + int64 duration_ms = 11; + string error = 12; + string result_json = 13; + string payload_json = 14; + string source_script_sha256 = 15; + string artifacts_dir = 16; +} + +message SetSchedulerEnabledRequest { + ProjectRef project = 1; + string agent_name = 2; + bool enabled = 3; +} + +message SetSchedulerEnabledResponse { + ProjectScheduler scheduler = 1; + bool overridden = 2; +} + +message SetSchedulerTriggerEnabledRequest { + ProjectRef project = 1; + string agent_name = 2; + string trigger_id = 3; + bool enabled = 4; +} + +message SetSchedulerTriggerEnabledResponse { + ResolvedTrigger trigger = 1; +} + +message ProjectValidationIssue { + ProjectValidationSeverity severity = 1; + string path = 2; + string message = 3; +} + +message ProjectChange { + ProjectChangeAction action = 1; + string resource_type = 2; + string resource_id = 3; + string name = 4; + string message = 5; +} + +message ProjectSpec { + string name = 1; + repeated EnvVarSpec variables = 2; + reserved 3; + reserved "workspace"; + repeated AgentSpec agents = 4; + NetworkSpec network = 5; + repeated ProjectVolumeSpec volumes = 6; + repeated NamedWorkspaceSpec workspaces = 7; + repeated MCPServerSpec mcps = 8; +} + +message NamedWorkspaceSpec { + string name = 1; + WorkspaceSpec workspace = 2; +} + +message AgentSpec { + string name = 1; + string provider = 2; + string model = 3; + string system_prompt = 4; + string image = 5; + DriverSpec driver = 6; + repeated EnvVarSpec env = 7; + WorkspaceSpec workspace = 8; + SchedulerSpec scheduler = 9; + repeated string capset_ids = 10; + JupyterSpec jupyter = 11; + BuildSpec build = 12; + repeated VolumeMountSpec volumes = 13; + repeated MCPServerSpec mcps = 14; + repeated SkillSpec skills = 15; + AgentStatus status = 16; +} + +message MCPServerSpec { + string name = 1; + string type = 2; + string transport = 3; + string command = 4; + repeated string args = 5; + repeated EnvVarSpec env = 6; + string url = 7; + repeated EnvVarSpec headers = 8; +} + +message ProjectVolumeSpec { + string key = 1; + string name = 2; + string driver = 3; + bool external = 4; + map labels = 5; + map options = 6; +} + +message VolumeMountSpec { + string type = 1; + string source = 2; + string target = 3; + bool read_only = 4; +} + +message BuildSpec { + string context = 1; + string dockerfile = 2; + string target = 3; + map args = 4; + repeated string platforms = 5; + repeated string tags = 6; + bool no_cache = 7; + bool pull = 8; +} + +message EnvVarSpec { + string name = 1; + string value = 2; + bool secret = 3; +} + +message EnvVarUpdateSpec { + string name = 1; + optional string value = 2; + bool secret = 3; +} + +message WorkspaceSpec { + string provider = 1; + string url = 2; + string branch = 3; + string path = 4; + string name = 5; +} + +message NetworkSpec { + string mode = 1; +} + +message SchedulerSpec { + bool enabled = 1; + repeated TriggerSpec triggers = 2; + string script = 3; + string sandbox_policy = 4; +} + +message TriggerSpec { + string name = 1; + string kind = 2; + string cron = 3; + string interval = 4; + string timeout = 5; + EventTriggerSpec event = 6; + string prompt = 7; + string sandbox_policy = 8; +} + +message EventTriggerSpec { + string topic = 1; +} + +message DriverSpec { + string name = 1; + BoxliteDriverSpec boxlite = 2; + DockerDriverSpec docker = 3; + MicrosandboxDriverSpec microsandbox = 4; +} + +message BoxliteDriverSpec { + string kernel = 1; + string rootfs = 2; +} + +message DockerDriverSpec { + string host = 1; +} + +message MicrosandboxDriverSpec { + string profile = 1; +} + +message RunAgentRequest { + reserved 5; + reserved "session_id"; + + string project_id = 1; + string agent_name = 2; + string prompt = 3; + RunSource source = 4; + repeated EnvVarSpec env = 6; + RunSandboxCleanupPolicy cleanup_policy = 7; + string scheduler_id = 8; + string trigger_id = 9; + string output_schema_json = 10; + string client_request_id = 11; + string command = 12; + RunJupyterSpec jupyter = 13; + string driver = 14; + string sandbox_id = 15; + repeated VolumeMountSpec volumes = 16; + string payload_json = 17; +} + +message RunAgentResponse { + RunDetail run = 1; + repeated string warnings = 2; +} + +message RunAgentStreamResponse { + RunAgentStreamEventType event_type = 1; + RunSummary run = 2; + string run_id = 3; + string chunk = 4; + StdioStream stream = 5; + string created_at = 6; + repeated string warnings = 7; + TranscriptEvent transcript = 8; +} + +message RunAttachRequest { + string client_frame_id = 15; + + oneof frame { + RunAttachStart start = 1; + AttachStdin stdin = 2; + AttachStdinEOF stdin_eof = 3; + AttachResize resize = 4; + AttachSignal signal = 5; + AttachHumanMessage human_message = 6; + AttachCancel cancel = 7; + } +} + +message RunAttachResponse { + string server_frame_id = 15; + string created_at = 16; + + oneof frame { + AttachStarted started = 1; + AttachOutput output = 2; + AttachAgentEvent agent_event = 3; + AttachAgentTurnCompleted agent_turn_completed = 4; + AttachResult result = 5; + AttachError error = 6; + } +} + +message RunAttachStart { + RunAgentRequest request = 1; + AttachRunMode mode = 2; + bool attach_stdin = 3; + bool tty = 4; + AttachTerminalSize terminal_size = 5; +} + +message TranscriptEvent { + StdioStream stream = 1; + string text = 2; + string name = 4; + string payload_json = 5; + string created_at = 6; +} + +message GetRunRequest { + string run_id = 1; + string project_id = 2; +} + +message GetRunResponse { + RunDetail run = 1; +} + +message ListRunsRequest { + reserved 3; + reserved "session_id"; + + string project_id = 1; + string agent_name = 2; + string scheduler_id = 4; + RunStatus status = 5; + RunSource source = 6; + string started_from = 7; + string started_to = 8; + uint32 offset = 9; + uint32 limit = 10; + string sandbox_id = 11; +} + +message ListRunsResponse { + repeated RunSummary runs = 1; +} + +message FollowRunLogsRequest { + string project_id = 1; + string run_id = 2; + uint32 tail_lines = 3; + uint64 start_offset = 4; + bool follow = 5; +} + +message RunLogChunk { + string data = 1; + uint64 offset = 2; + bool is_final = 3; + RunStatus run_status = 4; + string created_at = 5; +} + +message StopRunRequest { + string run_id = 1; + string reason = 2; +} + +message StopRunResponse { + RunDetail run = 1; + bool stop_requested = 2; +} + +message ListRunEventsRequest { + string run_id = 1; + uint32 limit = 2; + string cursor = 3; +} + +message RunEvent { + string id = 1; + string run_id = 2; + uint64 seq = 3; + RunEventKind kind = 4; + string text = 5; + string agent = 6; + string name = 7; + string payload_json = 8; + bool success = 9; + int32 exit_code = 10; + string stop_reason = 11; + google.protobuf.Timestamp created_at = 12; +} + +message ListRunEventsResponse { + repeated RunEvent events = 1; + string next_cursor = 2; + bool history_available = 3; +} + +message ListSandboxRunEventsRequest { + string sandbox_id = 1; + uint32 limit = 2; + string cursor = 3; +} + +message ListSandboxRunEventsResponse { + repeated RunEvent events = 1; + string next_cursor = 2; + repeated string history_available_run_ids = 3; +} + +message RemoveSandboxRequest { + string sandbox_id = 1; + bool force = 2; +} + +message RemoveSandboxResponse { + string sandbox_id = 1; + bool stopped = 2; + bool removed = 3; +} + +message GetSandboxStatsRequest { + string sandbox_id = 1; +} + +message GetSandboxStatsResponse { + SandboxStats stats = 1; +} + +message GetSandboxRequest { + string sandbox_id = 1; +} + +message Sandbox { + string sandbox_id = 1; + string status = 2; + string driver = 3; + string project_id = 4; + string agent_name = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; + string image = 8; + string workspace_path = 9; + repeated SandboxTag tags = 10; + string title = 11; + string proxy_path = 12; + string trigger_source = 13; + uint32 cell_count = 14; + uint32 event_count = 15; + string notebook_url = 16; +} + +message SandboxTag { + string name = 1; + string value = 2; +} + +message ListSandboxesRequest { + uint32 limit = 1; + string cursor = 2; +} + +message ListSandboxesResponse { + repeated Sandbox sandboxes = 1; + string next_cursor = 2; +} + +message GetSandboxResponse { + Sandbox sandbox = 1; +} + +message StopSandboxRequest { + string sandbox_id = 1; +} + +message StopSandboxResponse { + Sandbox sandbox = 1; +} + +message ResumeSandboxRequest { + string sandbox_id = 1; +} + +message ResumeSandboxResponse { + Sandbox sandbox = 1; +} + +message MetricValue { + optional double value = 1; + string unit = 2; + MetricStatus status = 3; + string message = 4; +} + +message SandboxStats { + string sandbox_id = 1; + string driver = 2; + string sampled_at = 3; + MetricValue cpu_percent = 4; + MetricValue memory_usage_bytes = 5; + MetricValue memory_limit_bytes = 6; + MetricValue memory_percent = 7; + MetricValue network_rx_bytes = 8; + MetricValue network_tx_bytes = 9; + MetricValue block_read_bytes = 10; + MetricValue block_write_bytes = 11; + MetricValue uptime_seconds = 12; +} + +message RunSummary { + reserved 11; + reserved "session_id"; + + string run_id = 1; + string project_id = 2; + string project_name = 3; + uint64 project_revision = 4; + string agent_id = 5; + string agent_name = 6; + RunSource source = 7; + string scheduler_id = 8; + string trigger_id = 9; + RunStatus status = 10; + int32 exit_code = 12; + string error = 13; + string started_at = 14; + string completed_at = 15; + int64 duration_ms = 16; + string created_at = 17; + string updated_at = 18; + repeated string warnings = 19; + string sandbox_id = 20; + string run_short_id = 21; + string sandbox_short_id = 22; +} + +message RunDetail { + RunSummary summary = 1; + string prompt = 2; + string output = 3; + string result_json = 4; + string logs_path = 5; + string artifacts_dir = 6; + string cleanup_error = 7; + string driver = 8; + string image_ref = 9; + repeated string warnings = 10; +} + +message ExecRequest { + reserved "session_id"; + + oneof target { + string sandbox_id = 1; + string run_id = 2; + ExecSandboxSelector selector = 3; + } + ExecCommand command = 4; + string cwd = 5; + repeated EnvVarSpec env = 6; + uint32 timeout_ms = 7; + uint32 max_output_bytes = 8; +} + +message ExecSandboxSelector { + string project_id = 1; + string project_name = 2; + string agent_name = 3; +} + +message ExecCommand { + string command = 1; + repeated string args = 2; +} + +message ExecResponse { + ExecResult result = 1; +} + +message ExecStreamResponse { + reserved "session_id"; + + ExecStreamEventType event_type = 1; + string exec_id = 2; + string sandbox_id = 3; + string run_id = 4; + string chunk = 5; + StdioStream stream = 6; + ExecResult result = 7; + TranscriptEvent transcript = 8; +} + +message ExecAttachRequest { + string client_frame_id = 15; + + oneof frame { + ExecAttachStart start = 1; + AttachStdin stdin = 2; + AttachStdinEOF stdin_eof = 3; + AttachResize resize = 4; + AttachSignal signal = 5; + AttachCancel cancel = 6; + AttachHumanMessage human_message = 7; + } +} + +message ExecAttachResponse { + string server_frame_id = 15; + string created_at = 16; + + oneof frame { + AttachStarted started = 1; + AttachOutput output = 2; + AttachResult result = 3; + AttachError error = 4; + AttachAgentEvent agent_event = 5; + AttachAgentTurnCompleted agent_turn_completed = 6; + } +} + +message ExecAttachStart { + ExecRequest request = 1; + bool attach_stdin = 2; + bool tty = 3; + AttachTerminalSize terminal_size = 4; + AttachRunMode mode = 5; + string prompt = 6; +} + +message AttachTerminalSize { + uint32 rows = 1; + uint32 cols = 2; +} + +message AttachStdin { + bytes data = 1; +} + +message AttachStdinEOF { +} + +message AttachResize { + AttachTerminalSize terminal_size = 1; +} + +message AttachSignal { + string signal = 1; +} + +message AttachHumanMessage { + string text = 1; + map metadata = 2; +} + +message AttachCancel { + string reason = 1; +} + +message AttachStarted { + string operation_id = 1; + string exec_id = 2; + string run_id = 3; + string sandbox_id = 4; + RunSummary run = 5; + repeated string warnings = 6; +} + +message AttachOutput { + bytes data = 1; + StdioStream stream = 2; + bool tty = 3; + TranscriptEvent transcript = 4; +} + +message AttachAgentEvent { + string name = 1; + string text = 2; + string payload_json = 3; + string created_at = 4; +} + +message AttachAgentTurnCompleted { + string run_id = 1; + string result_json = 2; + repeated string warnings = 3; +} + +message AttachResult { + int32 exit_code = 1; + bool success = 2; + ExecResult exec_result = 3; + RunSummary run = 4; + string output = 5; + string result_json = 6; + string error = 7; +} + +message AttachError { + string code = 1; + string message = 2; + bool terminal = 3; + map details = 4; +} + +message ExecResult { + reserved "session_id"; + + string exec_id = 1; + string sandbox_id = 2; + string run_id = 3; + ExecCommand command = 4; + string cwd = 5; + int32 exit_code = 6; + bool success = 7; + string stdout = 8; + string stderr = 9; + string output = 10; + bool stdout_truncated = 11; + bool stderr_truncated = 12; + bool output_truncated = 13; + string error = 14; +} + +message ListImagesRequest { + ImageStoreKind store = 1; + string query = 2; + bool all = 3; + bool include_cache_status = 4; + uint32 offset = 5; + uint32 limit = 6; +} + +message ListImagesResponse { + repeated Image images = 1; + uint32 total_count = 2; + bool has_more = 3; + uint32 next_offset = 4; + ImageStoreStatus store_status = 5; +} + +message PullImageRequest { + string image_ref = 1; + ImageStoreKind store = 2; + ImagePlatform platform = 3; +} + +message PullImageResponse { + Image image = 1; + ImageOperationStatus status = 2; + string resolved_ref = 3; + repeated ImagePullProgress progress = 4; + repeated string warnings = 5; +} + +message InspectImageRequest { + string image_ref = 1; + ImageStoreKind store = 2; + bool include_cache_status = 3; +} + +message InspectImageResponse { + Image image = 1; + ImageStoreStatus store_status = 2; +} + +message RemoveImageRequest { + string image_ref = 1; + ImageStoreKind store = 2; + bool force = 3; + bool prune_children = 4; +} + +message RemoveImageResponse { + string image_ref = 1; + repeated string untagged_refs = 2; + repeated string deleted_ids = 3; + repeated string warnings = 4; +} + +message BuildImageRequest { + string context_dir = 1; + string dockerfile = 2; + repeated string tags = 3; + map build_args = 4; + string target = 5; + ImageStoreKind store = 6; + ImagePlatform platform = 7; + bool no_cache = 8; + bool pull = 9; +} + +message BuildImageEvent { + ImageOperationStatus status = 1; + string stage = 2; + string message = 3; + Image image = 4; + string image_ref = 5; + string resolved_ref = 6; + repeated string warnings = 7; +} + +message CacheFilter { + string driver = 1; + CacheDomain domain = 2; + string type = 3; + CacheStatus status = 4; + uint64 older_than_seconds = 5; + string cache_id = 6; +} + +message ListCachesRequest { + CacheFilter filter = 1; +} + +message ListCachesResponse { + repeated CacheItem caches = 1; + repeated string warnings = 2; +} + +message InspectCacheRequest { + string cache_id = 1; +} + +message InspectCacheResponse { + CacheItem cache = 1; + repeated string warnings = 2; +} + +message PruneCachesRequest { + CacheFilter filter = 1; + bool include_referenced = 2; + bool force = 3; +} + +message PruneCachesResponse { + bool dry_run = 1; + repeated CacheItem matched = 2; + repeated string removed = 3; + repeated CacheItem skipped = 4; + repeated string warnings = 5; +} + +message RemoveCacheRequest { + string cache_id = 1; + bool force = 2; +} + +message RemoveCacheResponse { + bool dry_run = 1; + repeated CacheItem matched = 2; + repeated string removed = 3; + repeated CacheItem skipped = 4; + repeated string warnings = 5; +} + +message CacheItem { + reserved 10; + reserved "session_id"; + + string cache_id = 1; + CacheDomain domain = 2; + string driver = 3; + string kind = 4; + string path = 5; + uint64 size_bytes = 6; + string image_id = 7; + string image_ref = 8; + string resolved_ref = 9; + string sandbox_id = 11; + CacheStatus status = 12; + bool removable = 13; + repeated string blocked_reasons = 14; + string last_used_at = 15; + string last_used_source = 16; + repeated CacheReference references = 17; + repeated string warnings = 18; +} + +message CacheReference { + string type = 1; + string id = 2; + string name = 3; + string path = 4; + string status = 5; + string description = 6; +} + +message ListVolumesRequest { + string query = 1; + string driver = 2; + string project_id = 3; +} + +message ListVolumesResponse { + repeated Volume volumes = 1; +} + +message CreateVolumeRequest { + string name = 1; + string driver = 2; + map labels = 3; + map options = 4; +} + +message CreateVolumeResponse { + Volume volume = 1; + bool created = 2; +} + +message InspectVolumeRequest { + string name = 1; +} + +message InspectVolumeResponse { + Volume volume = 1; +} + +message RemoveVolumeRequest { + string name = 1; + bool force = 2; +} + +message RemoveVolumeResponse { + string name = 1; + bool removed = 2; +} + +message PruneVolumesRequest { + string query = 1; + string driver = 2; + string project_id = 3; + bool force = 4; +} + +message PruneVolumesResponse { + bool dry_run = 1; + repeated Volume matched = 2; + repeated Volume removed = 3; + repeated Volume skipped = 4; +} + +message Volume { + string name = 1; + string driver = 2; + string path = 3; + map labels = 4; + map options = 5; + string project_id = 6; + string created_at = 7; + string updated_at = 8; +} + +message Image { + string image_id = 1; + string image_ref = 2; + string resolved_ref = 3; + repeated string repo_tags = 4; + repeated string repo_digests = 5; + ImageStoreKind store = 6; + ImageAvailabilityStatus availability_status = 7; + ImagePlatform platform = 8; + uint64 size_bytes = 9; + uint64 virtual_size_bytes = 10; + string created_at = 11; + string inspected_at = 12; + bool dangling = 13; + uint64 container_count = 14; + DockerImageStatus docker = 15; + OCIImageStatus oci = 16; + map labels = 17; +} + +message ImagePlatform { + string os = 1; + string architecture = 2; + string variant = 3; + string os_version = 4; +} + +message ImageStoreStatus { + ImageStoreKind store = 1; + bool available = 2; + string endpoint = 3; + string error = 4; +} + +message DockerImageStatus { + bool local = 1; + string parent_id = 2; + int64 shared_size_bytes = 3; +} + +message OCIImageStatus { + bool layout_cached = 1; + bool rootfs_cached = 2; + string cache_key = 3; + string manifest_digest = 4; + string config_digest = 5; + string media_type = 6; +} + +message ImagePullProgress { + string id = 1; + string status = 2; + string progress = 3; + uint64 current_bytes = 4; + uint64 total_bytes = 5; +} + +message JupyterSpec { + bool enabled = 1; + uint32 guest_port = 2; +} + +message RunJupyterSpec { + bool enabled = 1; + uint32 guest_port = 2; + bool expose = 3; +} + +message StartRunRequest { + RunAgentRequest run = 1; +} + +message StartRunResponse { + RunSummary run = 1; + repeated string warnings = 2; + bool started = 3; +} + +message SkillSpec { + string name = 1; + string source = 2; + string url = 3; + string path = 4; + string ref = 5; + string username = 6; + string password = 7; + string token = 8; +} + +message GetDashboardOverviewRequest {} +message WatchDashboardOverviewRequest {} + +message RunOverview { + uint32 running_count = 1; + uint32 recent_count = 2; + uint32 attention_count = 3; +} + +message DashboardOverview { + RunOverview runs = 1; + google.protobuf.Timestamp updated_at = 2; +} + +message GetDashboardOverviewResponse { DashboardOverview overview = 1; } +message WatchDashboardOverviewResponse { + DashboardOverview overview = 1; + string reason = 2; +} + +message GetGlobalEnvRequest {} +message GetGlobalEnvResponse { repeated EnvVarSpec env = 1; } +message UpdateGlobalEnvRequest { repeated EnvVarUpdateSpec env = 1; } +message UpdateGlobalEnvResponse { repeated EnvVarSpec env = 1; } + +message GetCapabilityGatewayConfigRequest {} +message CapabilityGatewayConfig { + string addr = 1; + bool token_set = 2; +} +message GetCapabilityGatewayConfigResponse { CapabilityGatewayConfig config = 1; } +message UpdateCapabilityGatewayConfigRequest { + optional string addr = 1; + optional string token = 2; +} +message UpdateCapabilityGatewayConfigResponse { CapabilityGatewayConfig config = 1; } + +message WorkspacePreset { + string id = 1; + string name = 2; + string type = 3; + string config_json = 4; + string comment = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} +message ListWorkspacePresetsRequest {} +message ListWorkspacePresetsResponse { repeated WorkspacePreset presets = 1; } +message CreateWorkspacePresetRequest { + string name = 1; + string type = 2; + string config_json = 3; + string comment = 4; +} +message UpdateWorkspacePresetRequest { + string preset_id = 1; + string name = 2; + string type = 3; + string config_json = 4; + string comment = 5; +} +message DeleteWorkspacePresetRequest { string preset_id = 1; } +message DeleteWorkspacePresetResponse {} +message WorkspacePresetResponse { WorkspacePreset preset = 1; } + +message GetCapabilityStatusRequest {} +message CapabilityStatusResponse { + bool configured = 1; + bool ok = 2; + string status = 3; + uint32 service_count = 4; + string error = 5; + bool runtime_configured = 6; + bool proxy_listen_configured = 7; + bool proxy_target_configured = 8; +} +message ListCapabilitySetsRequest {} +message CapabilitySet { + string id = 1; + string name = 2; + string description = 3; + bool enabled = 4; +} +message ListCapabilitySetsResponse { repeated CapabilitySet capsets = 1; } +message GetCapabilityCatalogRequest { string capset_id = 1; } +message CapabilityEndpoint { + string protocol = 1; + string endpoint = 2; + string method_path = 3; + map metadata = 4; + string tool_name = 5; + string procedure = 6; + string http_method = 7; + repeated string content_types = 8; +} +message CapabilityMethod { + string service_id = 1; + string instance_id = 2; + string runtime_mode = 3; + string method_full_name = 4; + string request_message_full_name = 5; + string response_message_full_name = 6; + string backend_instance_status = 7; + repeated CapabilityEndpoint endpoints = 8; +} +message GetCapabilityCatalogResponse { + string capset_id = 1; + string name = 2; + string description = 3; + repeated CapabilityMethod methods = 4; +} + +message ListSandboxHistoryRequest { + string sandbox_id = 1; +} + +message SandboxHistoryCell { + string id = 1; + string type = 2; + string source = 3; + string stdout = 4; + string stderr = 5; + string output = 6; + int32 exit_code = 7; + bool success = 8; + bool running = 9; + google.protobuf.Timestamp created_at = 10; + string agent = 11; + string agent_thread_id = 12; + string stop_reason = 13; +} + +message SandboxHistoryEvent { + string id = 1; + string type = 2; + string level = 3; + string message = 4; + google.protobuf.Timestamp created_at = 5; +} + +message ListSandboxHistoryResponse { + repeated SandboxHistoryCell cells = 1; + repeated SandboxHistoryEvent events = 2; + bool legacy_history = 3; +} + +message WatchSandboxRequest { + string sandbox_id = 1; +} + +enum SandboxWatchEventType { + SANDBOX_WATCH_EVENT_TYPE_UNSPECIFIED = 0; + SANDBOX_WATCH_EVENT_TYPE_SANDBOX_UPDATED = 1; + SANDBOX_WATCH_EVENT_TYPE_CELL_STARTED = 2; + SANDBOX_WATCH_EVENT_TYPE_CELL_OUTPUT = 3; + SANDBOX_WATCH_EVENT_TYPE_CELL_COMPLETED = 4; + SANDBOX_WATCH_EVENT_TYPE_EVENT_ADDED = 5; +} + +message WatchSandboxResponse { + SandboxWatchEventType event_type = 1; + Sandbox sandbox = 2; + SandboxHistoryCell cell = 3; + SandboxHistoryEvent event = 4; + string cell_id = 5; + string chunk = 6; + StdioStream stream = 7; +} diff --git a/script-service/app.mjs b/script-service/app.mjs new file mode 100644 index 0000000..4fae8e3 --- /dev/null +++ b/script-service/app.mjs @@ -0,0 +1,197 @@ +import { createServer } from 'node:http'; +import { timingSafeEqual } from 'node:crypto'; +import { ServiceError } from './errors.mjs'; +import { createStorage } from './storage.mjs'; +import { createMetadataStore } from './metadata.mjs'; + +const PREFIX = '/script-api/v1'; + +function tokenMatches(actual, expected) { + const left = Buffer.from(String(actual ?? '')); + const right = Buffer.from(expected); + return left.length === right.length && timingSafeEqual(left, right); +} + +// Tokens the script-service refuses at startup. The leaked value below was +// previously shipped in docker/.env.example; keep rejecting it so no +// deployment that copied it verbatim keeps running with a publicly-known token. +const WEAK_TOKENS = new Set([ + 'replace-me', + 'changeme', + 'change-me', + 'password', + 'secret', + 'token', + 'your-token', + 'your-token-here', + 'please-run-openssl-rand-hex-32', + 'cf3675f0466c32d703281eba8656d24d4458b5f2fb824c51e8b3fd14b34cd0fd', +]); + +// Deployment-time token strength policy, enforced by server.mjs at startup. +// createScriptService() below only requires *a* non-empty token (so tests can +// pass short fixtures); the entry point applies this stronger check. +export function assertUsableToken(token) { + if (typeof token !== 'string' || token.length === 0) { + throw new Error('SCRIPT_SERVICE_TOKEN is required'); + } + if (token.length < 16) { + throw new Error('SCRIPT_SERVICE_TOKEN is too short; generate one with `openssl rand -hex 32`'); + } + if (WEAK_TOKENS.has(token.toLowerCase())) { + throw new Error('SCRIPT_SERVICE_TOKEN is a known placeholder; generate a fresh one with `openssl rand -hex 32`'); + } +} + +async function readJsonBody(req, maxBodyBytes) { + const chunks = []; + let size = 0; + let tooLarge = false; + for await (const chunk of req) { + size += chunk.length; + if (tooLarge) continue; + if (size > maxBodyBytes) { + tooLarge = true; + continue; + } + chunks.push(chunk); + } + if (tooLarge) throw new ServiceError(413, 'PAYLOAD_TOO_LARGE', '请求体过大'); + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + throw new ServiceError(400, 'INVALID_PATH', '请求体不是合法 JSON'); + } +} + +function sendJson(res, status, body) { + const text = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(text), + }); + res.end(text); +} + +function sendError(res, error) { + if (error instanceof ServiceError) { + if (error.status >= 500) console.error(`[script-service] ${error.code}:`, error.message); + sendJson(res, error.status, { error: { code: error.code, message: error.message, details: error.details } }); + return; + } + console.error(`[script-service] INTERNAL_ERROR:`, error?.stack ?? error); + sendJson(res, 500, { error: { code: 'INTERNAL_ERROR', message: '内部错误' } }); +} + +function pathSegments(pathname) { + return pathname.split('/').filter(Boolean).map((segment) => { + try { + return decodeURIComponent(segment); + } catch { + throw new ServiceError(400, 'INVALID_PATH', '路径编码无效'); + } + }); +} + +async function handleRequest({ storage, metadata, token, maxBodyBytes, req, res }) { + if (!tokenMatches(req.headers['x-script-service-token'], token)) { + throw new ServiceError(401, 'UNAUTHORIZED', '未授权'); + } + + const url = new URL(req.url, 'http://127.0.0.1'); + if (!url.pathname.startsWith(PREFIX)) throw new ServiceError(404, 'NOT_FOUND', '路径不存在'); + const segments = pathSegments(url.pathname.slice(PREFIX.length)); + const query = url.searchParams; + const method = req.method; + const [a, b, c] = segments; + + if (a === 'health' && method === 'GET') { + return sendJson(res, 200, { ok: true, version: '1' }); + } + + if (a === 'tree' && method === 'GET') { + return sendJson(res, 200, await storage.listTree()); + } + + if (a === 'files') { + if (method === 'GET') { + const filePath = query.get('path'); + if (!filePath) throw new ServiceError(400, 'INVALID_PATH', '缺少 path 参数'); + return sendJson(res, 200, await storage.readFile(filePath)); + } + if (method === 'PUT') { + const body = await readJsonBody(req, maxBodyBytes); + return sendJson(res, 200, await storage.writeFile(body)); + } + if (method === 'DELETE') { + const filePath = query.get('path'); + if (!filePath) throw new ServiceError(400, 'INVALID_PATH', '缺少 path 参数'); + const expectedSha256 = query.get('expectedSha256') || undefined; + return sendJson(res, 200, await storage.deleteFile(filePath, expectedSha256)); + } + } + + if (a === 'folders') { + if (method === 'POST') { + const body = await readJsonBody(req, maxBodyBytes); + if (!body?.path) throw new ServiceError(400, 'INVALID_PATH', '缺少 path'); + return sendJson(res, 201, await storage.createFolder(body.path)); + } + if (method === 'DELETE') { + const folderPath = query.get('path'); + if (!folderPath) throw new ServiceError(400, 'INVALID_PATH', '缺少 path 参数'); + const recursive = query.get('recursive') === 'true'; + return sendJson(res, 200, await storage.deleteFolder(folderPath, recursive)); + } + } + + if (a === 'projects' && b) { + const projectId = b; + if (!c && method === 'PUT') { + const body = await readJsonBody(req, maxBodyBytes); + if (!body?.projectName) throw new ServiceError(400, 'INVALID_PATH', '缺少 projectName'); + return sendJson(res, 200, await metadata.ensureProject({ projectId, projectName: body.projectName })); + } + if (!c && method === 'DELETE') { + return sendJson(res, 200, await metadata.deleteProject(projectId)); + } + if (c === 'manifest') { + if (method === 'GET') { + return sendJson(res, 200, await metadata.readManifest(projectId)); + } + if (method === 'PUT') { + const body = await readJsonBody(req, maxBodyBytes); + await metadata.writeManifest(projectId, body); + return sendJson(res, 200, { ok: true }); + } + if (method === 'DELETE') { + await metadata.deleteManifest(projectId); + return sendJson(res, 200, { ok: true }); + } + } + } + + throw new ServiceError(404, 'NOT_FOUND', '路径不存在'); +} + +export async function createScriptService({ root, token, maxBodyBytes, maxFileBytes }) { + if (!token) throw new Error('createScriptService: token is required'); + const storage = createStorage(root, { maxFileBytes }); + const metadata = await createMetadataStore(root); + + const server = createServer(async (req, res) => { + try { + await handleRequest({ storage, metadata, token, maxBodyBytes, req, res }); + } catch (error) { + if (res.headersSent) { + res.destroy(); + return; + } + sendError(res, error); + } + }); + + return server; +} diff --git a/script-service/app.test.mjs b/script-service/app.test.mjs new file mode 100644 index 0000000..3e47343 --- /dev/null +++ b/script-service/app.test.mjs @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createScriptService, assertUsableToken } from './app.mjs'; + +let root; +let server; +let baseUrl; +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'script-api-')); + const app = await createScriptService({ root, token: 'test-token', maxBodyBytes: 1024, maxFileBytes: 1024 }); + server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}/script-api/v1`; +}); +afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + await rm(root, { recursive: true, force: true }); +}); + +const request = (pathname, init = {}) => fetch(`${baseUrl}${pathname}`, { + ...init, + headers: { 'x-script-service-token': 'test-token', 'content-type': 'application/json', ...init.headers }, +}); + +test('rejects requests without the internal token', async () => { + const response = await fetch(`${baseUrl}/health`); + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ error: { code: 'UNAUTHORIZED' } }); +}); + +test('rejects requests with a wrong token', async () => { + const response = await fetch(`${baseUrl}/health`, { headers: { 'x-script-service-token': 'wrong' } }); + expect(response.status).toBe(401); +}); + +test('assertUsableToken rejects empty, short, and known-placeholder tokens', () => { + for (const bad of [ + '', + 'short', + 'secret', + 'replace-me', + 'changeme', + 'cf3675f0466c32d703281eba8656d24d4458b5f2fb824c51e8b3fd14b34cd0fd', + ]) { + expect(() => assertUsableToken(bad)).toThrow(); + } +}); + +test('assertUsableToken accepts a freshly generated 32-byte token', () => { + expect(() => assertUsableToken('a'.repeat(64))).not.toThrow(); +}); + +test('health returns ok', async () => { + const response = await request('/health'); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true }); +}); + +test('creates a folder and file, then lists and reads it', async () => { + expect((await request('/folders', { method: 'POST', body: JSON.stringify({ path: 'demo' }) })).status).toBe(201); + expect((await request('/files', { method: 'PUT', body: JSON.stringify({ path: 'demo/a.js', content: 'a', expectedSha256: null }) })).status).toBe(200); + expect(await (await request('/files?path=demo%2Fa.js')).json()).toMatchObject({ content: 'a' }); + expect(await (await request('/tree')).json()).toMatchObject({ children: [{ name: 'demo' }] }); +}); + +test('deletes a file via the API', async () => { + await request('/folders', { method: 'POST', body: JSON.stringify({ path: 'demo' }) }); + const written = await (await request('/files', { method: 'PUT', body: JSON.stringify({ path: 'demo/a.js', content: 'a', expectedSha256: null }) })).json(); + expect((await request(`/files?path=demo%2Fa.js&expectedSha256=${encodeURIComponent(written.sha256)}`, { method: 'DELETE' })).status).toBe(200); + expect((await request('/files?path=demo%2Fa.js')).status).toBe(404); +}); + +test('rejects an oversized JSON body', async () => { + const response = await request('/files', { method: 'PUT', body: JSON.stringify({ path: 'demo/a.js', content: 'x'.repeat(2048) }) }); + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ error: { code: 'PAYLOAD_TOO_LARGE' } }); +}); + +test('ensures a project and writes, reads, deletes a manifest', async () => { + expect((await request('/projects/sha256:proj1', { method: 'PUT', body: JSON.stringify({ projectName: 'data-pipeline' }) })).status).toBe(200); + const manifest = { version: 1, projectId: 'proj1', projectName: 'data-pipeline', references: [] }; + expect((await request('/projects/sha256:proj1/manifest', { method: 'PUT', body: JSON.stringify(manifest) })).status).toBe(200); + expect(await (await request('/projects/sha256:proj1/manifest')).json()).toMatchObject({ projectId: 'proj1' }); + expect((await request('/projects/missing/manifest')).status).toBe(200); + expect(await (await request('/projects/missing/manifest')).json()).toBeNull(); + expect((await request('/projects/sha256:proj1/manifest', { method: 'DELETE' })).status).toBe(200); + expect(await (await request('/projects/sha256:proj1/manifest')).json()).toBeNull(); +}); + +test('deletes a project directory and its metadata by project id', async () => { + expect((await request('/projects/sha256:delete-me', { method: 'PUT', body: JSON.stringify({ projectName: 'delete-me-dir' }) })).status).toBe(200); + expect((await request('/files', { method: 'PUT', body: JSON.stringify({ path: 'delete-me-dir/a.js', content: 'a', expectedSha256: null }) })).status).toBe(200); + const manifest = { version: 1, projectId: 'delete-me', projectName: 'delete-me-dir', references: [] }; + expect((await request('/projects/delete-me/manifest', { method: 'PUT', body: JSON.stringify(manifest) })).status).toBe(200); + + expect((await request('/projects/sha256:delete-me', { method: 'DELETE' })).status).toBe(200); + expect((await request('/files?path=delete-me-dir%2Fa.js')).status).toBe(404); + expect(await (await request('/projects/delete-me/manifest')).json()).toBeNull(); +}); + +test('rejects a path traversal attempt with 400', async () => { + const response = await request('/files?path=..%2Fsecret.js'); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: 'INVALID_PATH' } }); +}); + +test('returns 404 for unknown routes', async () => { + const response = await request('/unknown'); + expect(response.status).toBe(404); +}); diff --git a/script-service/errors.mjs b/script-service/errors.mjs new file mode 100644 index 0000000..c72cc66 --- /dev/null +++ b/script-service/errors.mjs @@ -0,0 +1,13 @@ +export class ServiceError extends Error { + constructor(status, code, message, details) { + super(message); + this.name = 'ServiceError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +export function invalidPath(message = '路径无效') { + return new ServiceError(400, 'INVALID_PATH', message); +} diff --git a/script-service/metadata.mjs b/script-service/metadata.mjs new file mode 100644 index 0000000..48b401f --- /dev/null +++ b/script-service/metadata.mjs @@ -0,0 +1,117 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { ServiceError } from './errors.mjs'; +import { validateProjectDirectoryName } from './paths.mjs'; + +const METADATA_DIR = '.metadata'; +const MANIFESTS_DIR = 'manifests'; +const PROJECTS_FILE = 'projects.json'; + +export function canonicalProjectId(projectId) { + const value = String(projectId ?? ''); + const canonical = value.startsWith('sha256:') ? value.slice(7) : value; + if (!canonical || !/^[A-Za-z0-9_-]+$/.test(canonical)) { + throw new ServiceError(400, 'INVALID_PATH', '项目 ID 无效'); + } + return canonical; +} + +async function writeJsonAtomic(target, value) { + const temporary = `${target}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + try { + await rename(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +async function readJsonOrNull(target) { + try { + const content = await readFile(target, 'utf8'); + try { + return JSON.parse(content); + } catch { + return null; + } + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } +} + +export async function createMetadataStore(root) { + const metadataDir = path.join(root, METADATA_DIR); + const manifestsDir = path.join(metadataDir, MANIFESTS_DIR); + const projectsFile = path.join(metadataDir, PROJECTS_FILE); + await mkdir(manifestsDir, { recursive: true, mode: 0o700 }); + + async function readProjects() { + const data = await readJsonOrNull(projectsFile); + if (!data || !Array.isArray(data.projects)) return { version: 1, projects: [] }; + return { version: 1, projects: data.projects }; + } + + async function writeProjects(projects) { + await writeJsonAtomic(projectsFile, projects); + } + + async function ensureProject({ projectId, projectName }) { + const canonical = canonicalProjectId(projectId); + const directory = validateProjectDirectoryName(projectName); + const projects = await readProjects(); + const existing = projects.projects.find((entry) => entry.directory === directory); + if (existing) { + if (existing.projectId !== canonical) { + throw new ServiceError(409, 'ALREADY_EXISTS', '目录已被其他项目占用'); + } + return { projectId: canonical, projectName: existing.projectName, directory }; + } + await mkdir(path.join(root, directory), { recursive: true }); + projects.projects.push({ projectId: canonical, projectName: directory, directory }); + await writeProjects(projects); + return { projectId: canonical, projectName: directory, directory }; + } + + function manifestPath(projectId) { + return path.join(manifestsDir, `${canonicalProjectId(projectId)}.json`); + } + + async function readManifest(projectId) { + const data = await readJsonOrNull(manifestPath(projectId)); + if (!data || typeof data !== 'object' || Array.isArray(data)) return null; + return data; + } + + async function writeManifest(projectId, manifest) { + const canonical = canonicalProjectId(projectId); + if (!manifest || typeof manifest !== 'object' || manifest.version !== 1 || + !manifest.projectId || !Array.isArray(manifest.references) || + canonicalProjectId(manifest.projectId) !== canonical) { + throw new ServiceError(400, 'INVALID_PATH', 'manifest 结构无效'); + } + await writeJsonAtomic(manifestPath(canonical), manifest); + } + + async function deleteManifest(projectId) { + await rm(manifestPath(projectId), { force: true }); + } + + async function deleteProject(projectId) { + const canonical = canonicalProjectId(projectId); + const projects = await readProjects(); + const project = projects.projects.find((entry) => entry.projectId === canonical); + if (!project) { + await deleteManifest(canonical); + return { deleted: false, projectId: canonical }; + } + await rm(path.join(root, project.directory), { recursive: true, force: true }); + await deleteManifest(canonical); + await writeProjects({ version: 1, projects: projects.projects.filter((entry) => entry.projectId !== canonical) }); + return { deleted: true, projectId: canonical, directory: project.directory }; + } + + return { ensureProject, readManifest, writeManifest, deleteManifest, deleteProject }; +} diff --git a/script-service/metadata.test.mjs b/script-service/metadata.test.mjs new file mode 100644 index 0000000..02f36d0 --- /dev/null +++ b/script-service/metadata.test.mjs @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createMetadataStore } from './metadata.mjs'; + +let root; +let metadata; +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'script-metadata-')); + metadata = await createMetadataStore(root); +}); +afterEach(async () => { await rm(root, { recursive: true, force: true }); }); + +test('ensures a project directory and persists a manifest', async () => { + expect(await metadata.ensureProject({ projectId: 'sha256:abc', projectName: 'data-pipeline' })) + .toMatchObject({ projectId: 'abc', directory: 'data-pipeline' }); + const manifest = { version: 1, projectId: 'abc', projectName: 'data-pipeline', references: [{ pointer: '/agents/data/scheduler/script', path: 'data-pipeline/a.js', contentSha256: 'sha256:1' }] }; + await metadata.writeManifest('sha256:abc', manifest); + expect(await metadata.readManifest('abc')).toEqual(expect.objectContaining(manifest)); +}); + +test('rejects two project IDs claiming one directory', async () => { + await metadata.ensureProject({ projectId: 'one', projectName: 'demo' }); + await expect(metadata.ensureProject({ projectId: 'two', projectName: 'demo' })).rejects.toMatchObject({ code: 'ALREADY_EXISTS' }); +}); + +test('missing and corrupt manifests safely return null', async () => { + expect(await metadata.readManifest('missing')).toBeNull(); + const corruptPath = path.join(root, '.metadata', 'manifests', 'corrupt.json'); + await writeFile(corruptPath, '{ not valid json', { mode: 0o600 }); + expect(await metadata.readManifest('corrupt')).toBeNull(); +}); + +test('deleteManifest removes a persisted manifest', async () => { + await metadata.ensureProject({ projectId: 'p1', projectName: 'demo' }); + await metadata.writeManifest('p1', { version: 1, projectId: 'p1', projectName: 'demo', references: [] }); + expect(await metadata.readManifest('p1')).not.toBeNull(); + await metadata.deleteManifest('p1'); + expect(await metadata.readManifest('p1')).toBeNull(); +}); + +test('rejects project IDs that could escape the manifest directory', async () => { + for (const projectId of ['../outside', 'folder/project', 'folder\\project', '.', 'sha256:']) { + await expect(metadata.readManifest(projectId)).rejects.toMatchObject({ code: 'INVALID_PATH' }); + await expect(metadata.ensureProject({ projectId, projectName: 'safe-name' })) + .rejects.toMatchObject({ code: 'INVALID_PATH' }); + } +}); + +test('rejects a manifest whose project ID does not match its URL', async () => { + await expect(metadata.writeManifest('p1', { + version: 1, + projectId: 'p2', + projectName: 'demo', + references: [], + })).rejects.toMatchObject({ code: 'INVALID_PATH' }); +}); diff --git a/script-service/paths.mjs b/script-service/paths.mjs new file mode 100644 index 0000000..4eb43ee --- /dev/null +++ b/script-service/paths.mjs @@ -0,0 +1,103 @@ +import { lstat, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { invalidPath, ServiceError } from './errors.mjs'; + +function pathSegments(relativePath) { + if ( + typeof relativePath !== 'string' || + !relativePath || + relativePath.includes('\\') || + relativePath.includes('\0') + ) { + throw invalidPath(); + } + + let decoded; + try { + decoded = decodeURIComponent(relativePath); + } catch { + throw invalidPath(); + } + + if (decoded !== relativePath || path.posix.isAbsolute(decoded)) { + throw invalidPath(); + } + + const parts = decoded.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..' || part === '.metadata')) { + throw invalidPath(); + } + return parts; +} + +async function rejectSymlinks(rootPath, parts, includeLeaf) { + let current = rootPath; + const limit = includeLeaf ? parts.length : Math.max(0, parts.length - 1); + + for (let index = 0; index < limit; index += 1) { + current = path.join(current, parts[index]); + try { + if ((await lstat(current)).isSymbolicLink()) { + throw invalidPath('不允许访问符号链接'); + } + } catch (error) { + if (error?.code === 'ENOENT') { + return; + } + throw error; + } + } +} + +async function resolveUnderRoot(root, relativePath, options = {}) { + const parts = pathSegments(relativePath); + const rootPath = await realpath(root); + const target = path.join(rootPath, ...parts); + const relativeTarget = path.relative(rootPath, target); + + if (!relativeTarget || relativeTarget === '..' || relativeTarget.startsWith(`..${path.sep}`) || path.isAbsolute(relativeTarget)) { + throw invalidPath(); + } + + const mustExist = options?.mustExist !== false; + await rejectSymlinks(rootPath, parts, mustExist); + + if (mustExist) { + try { + await lstat(target); + } catch (error) { + if (error?.code === 'ENOENT') { + throw new ServiceError(404, 'NOT_FOUND', '目标不存在'); + } + throw error; + } + } + + return target; +} + +export async function resolveScriptPath(root, relativePath, options = {}) { + const parts = pathSegments(relativePath); + if (!parts.at(-1).endsWith('.js')) { + throw invalidPath('仅允许 .js 文件'); + } + return resolveUnderRoot(root, relativePath, options); +} + +export async function resolveDirectoryPath(root, relativePath, options = {}) { + return resolveUnderRoot(root, relativePath, options); +} + +export function validateProjectDirectoryName(name) { + const value = String(name ?? '').trim(); + if ( + !value || + value === '.' || + value === '..' || + value === '.metadata' || + /[\\/\0-\x1f]/.test(value) + ) { + throw invalidPath('项目名称不能用作目录名'); + } + return value; +} diff --git a/script-service/paths.test.mjs b/script-service/paths.test.mjs new file mode 100644 index 0000000..9b0fe30 --- /dev/null +++ b/script-service/paths.test.mjs @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + resolveDirectoryPath, + resolveScriptPath, + validateProjectDirectoryName, +} from './paths.mjs'; + +let root; +let cleanupPaths; + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'script-paths-')); + cleanupPaths = [root]; +}); + +afterEach(async () => { + await Promise.all(cleanupPaths.map((entry) => rm(entry, { recursive: true, force: true }))); +}); + +describe('resolveScriptPath', () => { + test('accepts a js path under the canonical data root', async () => { + await mkdir(path.join(root, 'demo', 'scripts'), { recursive: true }); + + expect(await resolveScriptPath(root, 'demo/scripts/job.js', { mustExist: false })).toBe( + path.join(await realpath(root), 'demo', 'scripts', 'job.js'), + ); + }); + + test.each([ + '../secret.js', + '/tmp/secret.js', + 'demo/../secret.js', + '.metadata/x.js', + 'demo\\x.js', + 'demo/x.txt', + 'demo/%2e%2e/x.js', + 'demo//x.js', + 'demo/./x.js', + 'demo/x%2ejs', + 'demo/\0x.js', + ])('%s is rejected', async (value) => { + await expect(resolveScriptPath(root, value, { mustExist: false })).rejects.toMatchObject({ + status: 400, + code: 'INVALID_PATH', + }); + }); + + test('rejects a symlink in the path chain', async () => { + const outside = await mkdtemp(path.join(tmpdir(), 'script-outside-')); + cleanupPaths.push(outside); + await writeFile(path.join(outside, 'secret.js'), 'secret'); + await symlink(outside, path.join(root, 'linked')); + + await expect(resolveScriptPath(root, 'linked/secret.js')).rejects.toMatchObject({ + code: 'INVALID_PATH', + }); + }); + + test('checks existing parent segments for symlinks when the leaf may not exist', async () => { + const outside = await mkdtemp(path.join(tmpdir(), 'script-outside-')); + cleanupPaths.push(outside); + await symlink(outside, path.join(root, 'linked')); + + await expect( + resolveScriptPath(root, 'linked/new/nested.js', { mustExist: false }), + ).rejects.toMatchObject({ code: 'INVALID_PATH' }); + }); +}); + +describe('resolveDirectoryPath', () => { + test('accepts a nested directory path', async () => { + expect(await resolveDirectoryPath(root, 'demo/scripts', { mustExist: false })).toBe( + path.join(await realpath(root), 'demo', 'scripts'), + ); + }); + + test.each(['.metadata', 'demo/.metadata', 'demo//scripts', '../demo', '/tmp/demo'])( + '%s is rejected', + async (value) => { + await expect(resolveDirectoryPath(root, value, { mustExist: false })).rejects.toMatchObject({ + code: 'INVALID_PATH', + }); + }, + ); +}); + +describe('validateProjectDirectoryName', () => { + test('returns a readable single segment', () => { + expect(validateProjectDirectoryName('data-pipeline')).toBe('data-pipeline'); + }); + + test.each(['', '.', '..', '.metadata', 'a/b', 'a\\b', 'a\0b', 'line\nbreak'])( + '%s is rejected', + (name) => { + expect(() => validateProjectDirectoryName(name)).toThrowError( + expect.objectContaining({ status: 400, code: 'INVALID_PATH' }), + ); + }, + ); +}); diff --git a/script-service/server.mjs b/script-service/server.mjs new file mode 100644 index 0000000..06da081 --- /dev/null +++ b/script-service/server.mjs @@ -0,0 +1,22 @@ +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createScriptService, assertUsableToken } from './app.mjs'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const root = path.resolve(process.env.SCRIPT_DATA_DIR || path.join(webRoot, 'data', 'scripts')); +const token = process.env.SCRIPT_SERVICE_TOKEN; +assertUsableToken(token); +await mkdir(root, { recursive: true, mode: 0o700 }); +const server = await createScriptService({ + root, + token, + maxBodyBytes: 2 * 1024 * 1024 + 64 * 1024, + maxFileBytes: 2 * 1024 * 1024, +}); +const port = Number(process.env.SCRIPT_SERVICE_PORT || 7420); +const host = process.env.SCRIPT_SERVICE_HOST || '127.0.0.1'; +server.listen(port, host, () => console.log(`script service listening on ${host}:${port}`)); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/script-service/storage.mjs b/script-service/storage.mjs new file mode 100644 index 0000000..cd58520 --- /dev/null +++ b/script-service/storage.mjs @@ -0,0 +1,128 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readdir, readFile, rename, rm, rmdir } from 'node:fs/promises'; +import path from 'node:path'; +import { ServiceError } from './errors.mjs'; +import { resolveDirectoryPath, resolveScriptPath } from './paths.mjs'; + +export const sha256 = (content) => `sha256:${createHash('sha256').update(content).digest('hex')}`; + +export function createStorage(root, { maxFileBytes }) { + async function readScript(relativePath) { + const absolute = await resolveScriptPath(root, relativePath); + const info = await lstat(absolute); + if (!info.isFile()) throw new ServiceError(400, 'INVALID_PATH', '目标不是普通文件'); + if (info.size > maxFileBytes) throw new ServiceError(413, 'PAYLOAD_TOO_LARGE', '脚本文件过大'); + const content = await readFile(absolute, 'utf8'); + return { + path: relativePath, + content, + size: Buffer.byteLength(content), + mtimeMs: info.mtimeMs, + sha256: sha256(content), + }; + } + + async function walk(absolute, relative = '') { + const entries = await readdir(absolute, { withFileTypes: true }); + const children = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name === '.metadata' || entry.isSymbolicLink()) continue; + const childRelative = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + children.push({ + name: entry.name, + path: childRelative, + kind: 'directory', + children: (await walk(path.join(absolute, entry.name), childRelative)).children, + }); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + const value = await readScript(childRelative); + children.push({ + name: entry.name, + path: childRelative, + kind: 'file', + size: value.size, + mtimeMs: value.mtimeMs, + sha256: value.sha256, + }); + } + } + return { name: '', path: relative, kind: 'directory', children }; + } + + async function writeAtomic(target, content) { + const temporary = `${target}.${randomUUID()}.tmp`; + const handle = await open(temporary, 'wx', 0o600); + try { + await handle.writeFile(content, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, target); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + } + + return { + listTree: () => walk(root), + readFile: readScript, + async createFolder(relativePath) { + const absolute = await resolveDirectoryPath(root, relativePath, { mustExist: false }); + try { + const created = await mkdir(absolute, { recursive: true }); + if (created === undefined) throw new ServiceError(409, 'ALREADY_EXISTS', '目录已存在'); + } catch (error) { + if (error instanceof ServiceError) throw error; + if (error?.code === 'EEXIST') throw new ServiceError(409, 'ALREADY_EXISTS', '目录已存在'); + throw error; + } + return { path: relativePath }; + }, + async writeFile({ path: relativePath, content, expectedSha256 }) { + if (typeof content !== 'string' || Buffer.byteLength(content) > maxFileBytes) { + throw new ServiceError(413, 'PAYLOAD_TOO_LARGE', '脚本文件过大'); + } + const absolute = await resolveScriptPath(root, relativePath, { mustExist: false }); + let current = null; + try { + current = await readScript(relativePath); + } catch (error) { + if (error?.code !== 'NOT_FOUND') throw error; + } + if (current && expectedSha256 !== current.sha256) { + throw new ServiceError(409, 'CONTENT_CONFLICT', '磁盘脚本已变化', { currentSha256: current.sha256 }); + } + if (!current && expectedSha256) { + throw new ServiceError(409, 'CONTENT_CONFLICT', '脚本已被删除'); + } + await writeAtomic(absolute, content); + return readScript(relativePath); + }, + async deleteFile(relativePath, expectedSha256) { + const current = await readScript(relativePath); + if (expectedSha256 && current.sha256 !== expectedSha256) { + throw new ServiceError(409, 'CONTENT_CONFLICT', '磁盘脚本已变化'); + } + await rm(await resolveScriptPath(root, relativePath)); + return { deleted: true, path: relativePath }; + }, + async deleteFolder(relativePath, recursive) { + const absolute = await resolveDirectoryPath(root, relativePath); + if (recursive) { + await rm(absolute, { recursive: true }); + } else { + try { + await rmdir(absolute); + } catch (error) { + if (error?.code === 'ENOTEMPTY') throw new ServiceError(409, 'DIRECTORY_NOT_EMPTY', '目录不为空'); + throw error; + } + } + return { deleted: true, path: relativePath }; + }, + }; +} diff --git a/script-service/storage.test.mjs b/script-service/storage.test.mjs new file mode 100644 index 0000000..078eb2e --- /dev/null +++ b/script-service/storage.test.mjs @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createStorage } from './storage.mjs'; + +let root; +let storage; +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'script-storage-')); + storage = createStorage(root, { maxFileBytes: 2 * 1024 * 1024 }); +}); +afterEach(async () => { await rm(root, { recursive: true, force: true }); }); + +test('creates folders and writes, reads, lists, then deletes js files', async () => { + await storage.createFolder('demo/scripts'); + const written = await storage.writeFile({ path: 'demo/scripts/a.js', content: 'const a = 1;\n', expectedSha256: null }); + expect(written.sha256).toMatch(/^sha256:[a-f0-9]{64}$/); + expect(await storage.readFile('demo/scripts/a.js')).toMatchObject({ content: 'const a = 1;\n', sha256: written.sha256 }); + expect((await storage.listTree()).children[0].children[0].children[0]).toMatchObject({ name: 'a.js', kind: 'file' }); + await storage.deleteFile('demo/scripts/a.js', written.sha256); + await expect(storage.readFile('demo/scripts/a.js')).rejects.toMatchObject({ code: 'NOT_FOUND' }); +}); + +test('does not overwrite a file changed by another writer', async () => { + await storage.createFolder('demo'); + const first = await storage.writeFile({ path: 'demo/a.js', content: 'one', expectedSha256: null }); + await storage.writeFile({ path: 'demo/a.js', content: 'two', expectedSha256: first.sha256 }); + await expect(storage.writeFile({ path: 'demo/a.js', content: 'three', expectedSha256: first.sha256 })) + .rejects.toMatchObject({ code: 'CONTENT_CONFLICT' }); +}); + +test('recursive folder deletion requires the explicit flag', async () => { + await storage.createFolder('demo/scripts'); + await storage.writeFile({ path: 'demo/scripts/a.js', content: 'a', expectedSha256: null }); + await expect(storage.deleteFolder('demo', false)).rejects.toMatchObject({ code: 'DIRECTORY_NOT_EMPTY' }); + await storage.deleteFolder('demo', true); + expect((await storage.listTree()).children).toEqual([]); +}); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..2d1b747 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,49 @@ +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function childSpecs({ executable, token }) { + const env = { SCRIPT_SERVICE_TOKEN: token }; + return [ + { name: 'web', command: executable, args: ['run', 'dev:web'], env }, + { name: 'scripts', command: executable, args: ['run', 'dev:scripts'], env }, + ]; +} + +async function startDevelopment() { + const executable = process.execPath; + const token = randomBytes(32).toString('hex'); + const specs = childSpecs({ executable, token }); + + const children = specs.map((spec) => { + const child = spawn(spec.command, spec.args, { + stdio: 'inherit', + env: { ...process.env, ...spec.env }, + cwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), + }); + child.on('exit', (code, signal) => { + for (const other of children) { + if (other !== child && other.exitCode === null && !other.killed) { + other.kill('SIGTERM'); + } + } + if (code !== 0) { + process.exit(code ?? 1); + } + }); + return child; + }); + + const shutdown = (signal) => { + for (const child of children) { + if (child.exitCode === null && !child.killed) child.kill(signal); + } + }; + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => shutdown(signal)); + } +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) startDevelopment(); diff --git a/scripts/dev.test.mjs b/scripts/dev.test.mjs new file mode 100644 index 0000000..b3e57e2 --- /dev/null +++ b/scripts/dev.test.mjs @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test'; +import { childSpecs } from './dev.mjs'; + +test('starts Vite and the isolated script service with one shared token', () => { + const specs = childSpecs({ executable: '/usr/local/bin/bun', token: 'abc' }); + expect(specs).toEqual([ + { name: 'web', command: '/usr/local/bin/bun', args: ['run', 'dev:web'], env: { SCRIPT_SERVICE_TOKEN: 'abc' } }, + { name: 'scripts', command: '/usr/local/bin/bun', args: ['run', 'dev:scripts'], env: { SCRIPT_SERVICE_TOKEN: 'abc' } }, + ]); +}); + +test('childSpecs is a pure function and does not mutate inputs', () => { + const input = { executable: '/usr/local/bin/bun', token: 't' }; + const a = childSpecs(input); + const b = childSpecs(input); + expect(a).toEqual(b); + expect(a).not.toBe(b); +}); diff --git a/scripts/normalize-generated-eof.mjs b/scripts/normalize-generated-eof.mjs deleted file mode 100644 index 7070dff..0000000 --- a/scripts/normalize-generated-eof.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { readdir, readFile, writeFile } from 'node:fs/promises'; -import { extname, join } from 'node:path'; - -async function normalizeDirectory(directory) { - for (const entry of await readdir(directory, { withFileTypes: true })) { - const path = join(directory, entry.name); - if (entry.isDirectory()) { - await normalizeDirectory(path); - continue; - } - if (!entry.isFile() || extname(entry.name) !== '.ts') continue; - const source = await readFile(path, 'utf8'); - const normalized = `${source.trimEnd()}\n`; - if (normalized !== source) await writeFile(path, normalized); - } -} - -const directory = process.argv[2]; -if (!directory) throw new Error('generated source directory is required'); -await normalizeDirectory(directory); diff --git a/scripts/svelte-test-preload.mjs b/scripts/svelte-test-preload.mjs new file mode 100644 index 0000000..57baaaa --- /dev/null +++ b/scripts/svelte-test-preload.mjs @@ -0,0 +1,20 @@ +import { compileModule, compile } from 'svelte/compiler'; + +const tsTranspiler = new Bun.Transpiler({ loader: 'ts' }); + +Bun.plugin({ + name: 'svelte-test-loader', + setup(build) { + build.onLoad({ filter: /\.svelte\.ts$/ }, async (args) => { + const source = await Bun.file(args.path).text(); + const jsSource = tsTranspiler.transformSync(source); + const result = compileModule(jsSource, { generate: 'client', filename: args.path }); + return { contents: result.js.code, loader: 'js' }; + }); + build.onLoad({ filter: /\.svelte$/ }, async (args) => { + const source = await Bun.file(args.path).text(); + const result = compile(source, { generate: 'client', filename: args.path }); + return { contents: result.js.code, loader: 'js' }; + }); + }, +}); diff --git a/src/App.svelte b/src/App.svelte index b01d0bd..39e11ee 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,509 +1,98 @@ -{#if activePage === 'login'} - -{:else if !authReady} -
- -
-{:else if activePage === 'event-detail'} - +{#if eventId} +
{:else} -
- - -
-
-
- - 系统健康 - {statusError ? '异常' : statusLoading ? '同步中' : '正常'} - {health ? `${formatVersion(health.version)} · 运行 ${formatUptime(health.uptimeSeconds)}` : statusError || '等待健康检查'} -
-
- - - - - - -
-
- - +
+ +
+ {#if store.currentPage === 'dashboard'} + + {:else if store.currentPage === 'settings' || store.currentPage === 'images' || store.currentPage === 'environment'} + + {:else if store.currentPage === 'caches'} + + {:else if store.currentPage === 'volumes'} + + {:else if showRuntimePanel} +
+
+ +
+ {#if !store.editorCollapsed} + + {/if} +
+ +
-
-
- {#if activePage === 'runs'} - navigateToDebug(event.detail)} /> - {:else if activePage === 'agents'} - - {:else if activePage === 'automation-tasks'} - - {:else if activePage === 'settings'} - - {:else} - navigateRunsWithRun(event.detail)} /> - {/if} -
-
+ {/if} +
{/if} + + + diff --git a/src/api/agents.ts b/src/api/agents.ts deleted file mode 100644 index 4b49cb6..0000000 --- a/src/api/agents.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { AgentSpec, AgentStatus, DriverSpec, EnvVarSpec, ProjectAgentAvailability, ProjectAgentHealth, RunSandboxCleanupPolicy, RunSource, RunStatus, WorkspaceSpec, type Project, type ProjectAgent } from '../gen/agentcompose/v2/agentcompose_pb.js'; -import { projectClient, runClient } from './client'; -import { listWorkspacePresets, type WorkspacePreset } from './config'; - -export type AgentWorkFiles = { source: 'empty' | 'file' | 'git'; workspaceId: string; workspaceName: string; workspaceType: string; summary: string; configJson: string }; -export type AgentEnvItem = { name: string; value: string; secret: boolean }; -export type AgentRunSummary = { text: string; runningSessionCount: number; runningLoaderRunCount: number }; -export type AgentLatestRun = { runType: string; status: string; runId: string; title: string; at: string }; -export type AgentDefinition = { id:string;agentName:string;name:string;description:string;enabled:boolean;provider:string;model:string;systemPrompt:string;runtimeImageId:string;driver:string;guestImage:string;workspaceId:string;envItems:AgentEnvItem[];configJson:string;capsetIds:string[];availability:string;availabilityClass:'green'|'amber'|'red';health:string;healthClass:'green'|'amber'|'red';workFiles:AgentWorkFiles;currentRun:AgentRunSummary;latestRun:AgentLatestRun|null;createdAt:string;updatedAt:string;deletedAt:string;projectId?:string }; -export type AgentDefinitionInput = {agentName:string;name:string;description:string;enabled:boolean;provider:string;model:string;systemPrompt:string;runtimeImageId:string;driver:string;guestImage:string;workspaceId:string;envItems:AgentEnvItem[];configJson:string;capsetIds:string[]}; - -export async function listAgentDefinitions(query = ''): Promise { - const [projects, presets] = await Promise.all([listProjects(), listWorkspacePresets()]); - const result: AgentDefinition[] = []; - for (const summary of projects) { - const response = await projectClient.getProject({ project: { projectId: summary.projectId }, includeSpec: true }); - const project = response.project; - if (!project) continue; - for (const agent of project.agents) { - const spec = project.spec?.agents.find((value) => value.name === agent.agentName); - const mapped = agentFromV2(project, agent, spec, presets); - if (!query || `${mapped.name} ${mapped.agentName} ${mapped.description}`.toLowerCase().includes(query.toLowerCase())) { - result.push(mapped); - } - } - } - return result; -} - -export async function createAgentDefinition(input: AgentDefinitionInput): Promise { - const normalized = requestFromInput(input); - const [existing, presets] = await Promise.all([findUIProject(), listWorkspacePresets()]); - const agentName = validateStableAgentName(normalized.agentName); - if ((existing?.spec?.agents ?? []).some((value) => value.name === agentName)) { - throw new Error(`调用标识 ${agentName} 已存在`); - } - const agents = [...(existing?.spec?.agents ?? []), specFromInput(normalized, agentName, existing, presets)]; - const response = await projectClient.applyProject({ spec: { ...(existing?.spec ?? {}), name: existing?.spec?.name || 'ui-agents', agents } }); - const agent = response.project?.agents.find((value) => value.agentName === agentName); - if (!response.project || !agent) throw new Error('智能体保存失败'); - return agentFromV2(response.project, agent, response.project.spec?.agents.find((value) => value.name === agent.agentName), presets); -} - -export async function updateAgentDefinition(id: string, input: AgentDefinitionInput): Promise { - const found = await findAgent(id); - if (!found) throw new Error('智能体不存在'); - const normalized = requestFromInput(input); - const presets = await listWorkspacePresets(); - const currentSpec = found.project.spec?.agents.find((value) => value.name === found.agent.agentName); - const nextSpec = specFromInput(normalized, found.agent.agentName, found.project, presets, currentSpec); - const agents = (found.project.spec?.agents ?? []).map((value) => value.name === found.agent.agentName ? nextSpec : value); - const response = await projectClient.applyProject({ spec: { ...found.project.spec!, agents } }); - const agent = response.project?.agents.find((value) => value.agentName === found.agent.agentName); - if (!response.project || !agent) throw new Error('智能体保存失败'); - return agentFromV2(response.project, agent, response.project.spec?.agents.find((value) => value.name === agent.agentName), presets); -} -export async function deleteAgentDefinition(id:string):Promise{const found=await findAgent(id);if(!found)return;const agents=(found.project.spec?.agents??[]).filter((value)=>value.name!==found.agent.agentName);await projectClient.applyProject({spec:{...found.project.spec!,agents}})} -export async function runAgentDefinition(input:{agentId:string;driver:string;message:string}):Promise<{runId:string;sandboxId:string}>{const found=await findAgent(input.agentId);if(!found)throw new Error('智能体不存在');const response=await runClient.runAgent({projectId:found.project.summary?.projectId??'',agentName:found.agent.agentName,prompt:input.message,source:RunSource.MANUAL,driver:input.driver,cleanupPolicy:RunSandboxCleanupPolicy.KEEP_RUNNING});return{runId:response.run?.summary?.runId??'',sandboxId:response.run?.summary?.sandboxId??''}} - -async function listProjects(){const result=[];let offset=0;for(;;){const response=await projectClient.listProjects({limit:200,offset});result.push(...response.projects);if(!response.hasMore)break;offset=response.nextOffset}return result} -async function findUIProject():Promise{for(const summary of await listProjects()){if(summary.name==='ui-agents'){return (await projectClient.getProject({project:{projectId:summary.projectId},includeSpec:true})).project}}return undefined} -async function findAgent(id:string):Promise<{project:Project;agent:ProjectAgent}|undefined>{const target=parseAgentFallbackId(id);for(const summary of await listProjects()){const project=(await projectClient.getProject({project:{projectId:summary.projectId},includeSpec:true})).project;if(!project)continue;if(target&&project.summary?.projectId!==target.projectId)continue;const agent=target?project.agents.find((value)=>value.agentName===target.agentName):project.agents.find((value)=>(Boolean(id)&&value.managedAgentId===id)||value.agentName===id);if(agent)return{project,agent}}return undefined} -function requestFromInput(input:AgentDefinitionInput):AgentDefinitionInput{return{...input,agentName:input.agentName.trim(),name:input.name.trim(),description:input.description.trim(),provider:normalizeAgentProvider(input.provider),model:input.model.trim(),systemPrompt:input.systemPrompt.trim(),driver:input.driver.trim(),guestImage:input.guestImage.trim(),workspaceId:input.workspaceId.trim(),envItems:input.envItems.map((v)=>({...v,name:v.name.trim()})).filter((v)=>v.name),capsetIds:input.capsetIds.map((v)=>v.trim()).filter(Boolean)}} -function specFromInput(input: AgentDefinitionInput, agentName: string, project: Project | undefined, presets: WorkspacePreset[], existingSpec?: AgentSpec): AgentSpec { - return new AgentSpec({ - name: agentName, - displayName: input.name, - description: input.description, - provider: input.provider, - model: input.model, - systemPrompt: input.systemPrompt, - image: input.guestImage, - driver: input.driver ? new DriverSpec({ name: input.driver }) : undefined, - env: input.envItems.map((value) => new EnvVarSpec(value)), - workspace: workspaceSpecFromInput(input, project, presets, existingSpec), - capsetIds: input.capsetIds, - status: input.enabled ? AgentStatus.ENABLED : AgentStatus.DISABLED, - }); -} - -function workspaceSpecFromInput(input: AgentDefinitionInput, project: Project | undefined, presets: WorkspacePreset[], existingSpec?: AgentSpec): WorkspaceSpec | undefined { - if (!input.workspaceId) return undefined; - const preset = presets.find((value) => value.id === input.workspaceId); - if (!preset) throw new Error('Workspace 配置不存在'); - if (project && existingSpec?.workspace) { - const current = resolveAgentWorkspace(project, existingSpec, presets); - if (current.preset?.id === preset.id) return new WorkspaceSpec(existingSpec.workspace); - } - const projectWorkspace = projectWorkspaceForPreset(project, preset); - if (projectWorkspace) return new WorkspaceSpec({ name: projectWorkspace.name }); - return workspaceSpecFromPreset(preset); -} - -function workspaceSpecFromPreset(preset: WorkspacePreset): WorkspaceSpec { - const config = jsonObject(preset.configJson); - if (preset.type === 'git') { - const rawURL = stringValue(config.url) || stringValue(config.repo_url) || stringValue(config.repoUrl); - return new WorkspaceSpec({ - name: preset.id, - provider: 'git', - url: applyGitCredentials(rawURL, config), - branch: stringValue(config.branch), - commit: stringValue(config.commit), - path: stringValue(config.path) || '.', - }); - } - return new WorkspaceSpec({ name: preset.id, provider: 'local', path: stringValue(config.path) || preset.id }); -} - -function projectWorkspaceForPreset(project: Project | undefined, preset: WorkspacePreset) { - const workspaces = project?.spec?.workspaces ?? []; - const byID = workspaces.find((item) => item.name === preset.id); - if (byID) return byID; - const byDefinition = workspaces.filter((item) => workspaceMatchesPreset(item.workspace, preset)); - if (byDefinition.length === 1) return byDefinition[0]; - const byName = workspaces.filter((item) => item.name === preset.name.trim()); - return byName.length === 1 ? byName[0] : undefined; -} - -type AgentWorkspaceResolution = { - workspace?: WorkspaceSpec; - referenceName: string; - preset?: WorkspacePreset; -}; - -function resolveAgentWorkspace(project: Project, spec: AgentSpec | undefined, presets: WorkspacePreset[]): AgentWorkspaceResolution { - const declared = spec?.workspace; - if (!declared) return { referenceName: '' }; - const referenceName = declared.name.trim(); - const named = isNamedWorkspaceReference(declared) - ? project.spec?.workspaces.find((item) => item.name === referenceName) - : undefined; - const workspace = named?.workspace ?? declared; - return { - workspace, - referenceName, - preset: findWorkspacePreset(referenceName, workspace, presets), - }; -} - -function isNamedWorkspaceReference(workspace: WorkspaceSpec): boolean { - return Boolean(workspace.name.trim()) && !workspace.provider.trim() && !workspace.url.trim() && !workspace.branch.trim() && !workspace.commit.trim() && !workspace.path.trim(); -} - -function findWorkspacePreset(referenceName: string, workspace: WorkspaceSpec, presets: WorkspacePreset[]): WorkspacePreset | undefined { - const workspaceType = workspaceTypeFromProvider(workspace.provider); - const candidates = workspaceType ? presets.filter((preset) => preset.type === workspaceType) : presets; - const byID = candidates.find((preset) => preset.id === referenceName); - if (byID) return byID; - const byDefinition = candidates.filter((preset) => workspaceMatchesPreset(workspace, preset)); - if (byDefinition.length === 1) return byDefinition[0]; - const byName = candidates.filter((preset) => preset.name.trim() === referenceName); - return byName.length === 1 ? byName[0] : undefined; -} - -function workspaceMatchesPreset(workspace: WorkspaceSpec | undefined, preset: WorkspacePreset): boolean { - if (!workspace || workspaceTypeFromProvider(workspace.provider) !== preset.type) return false; - if (preset.type === 'git') { - const candidate = workspaceSpecFromPreset(preset); - return normalizedGitURL(candidate.url) === normalizedGitURL(workspace.url) - && candidate.branch.trim() === workspace.branch.trim() - && candidate.commit.trim() === workspace.commit.trim() - && normalizedWorkspacePath(candidate.path) === normalizedWorkspacePath(workspace.path); - } - const segments = workspace.path.split(/[\\/]+/).filter(Boolean); - return workspace.path.trim() === preset.id || segments.includes(preset.id); -} - -function workspaceTypeFromProvider(provider: string): string { - const normalized = provider.trim().toLowerCase(); - if (normalized === 'git') return 'git'; - if (normalized === 'local') return 'file'; - return ''; -} - -function normalizedWorkspacePath(path: string): string { - return path.trim() || '.'; -} - -function normalizedGitURL(value: string): string { - const trimmed = value.trim(); - if (!trimmed) return ''; - try { - const parsed = new URL(trimmed); - parsed.username = ''; - parsed.password = ''; - return parsed.toString(); - } catch { - return trimmed.replace(/^(https?:\/\/)[^/@]+@/i, '$1'); - } -} - -function applyGitCredentials(rawURL: string, config: Record): string { - const trimmedURL = rawURL.trim(); - if (!trimmedURL) return ''; - let credential = stringValue(config.credential); - if (!credential) { - const username = stringValue(config.username); - const password = stringValue(config.password); - if (username || password) credential = `${queryEscape(username)}:${queryEscape(password)}`; - } - if (!credential || trimmedURL.includes('@')) return trimmedURL; - for (const prefix of ['https://', 'http://']) { - if (trimmedURL.startsWith(prefix)) return `${prefix}${credential}@${trimmedURL.slice(prefix.length)}`; - } - return trimmedURL; -} - -function queryEscape(value: string): string { - return encodeURIComponent(value).replace(/%20/g, '+'); -} -function jsonObject(value:string):Record{try{const parsed=JSON.parse(value||'{}');return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed as Record:{}}catch{return{}}} -function stringValue(value:unknown):string{return typeof value==='string'?value.trim():''} -function validateStableAgentName(value:string):string{const normalized=value.trim();if(!/^[a-z][a-z0-9_-]*$/.test(normalized))throw new Error('调用标识只能使用小写字母开头,并包含小写字母、数字、- 或 _');return normalized} -function normalizeAgentProvider(value:string):string{const provider=value.trim().toLowerCase();if(['claude','gemini','codex','opencode'].includes(provider))return provider;throw new Error(`不支持的智能体 Provider:${value||'-'}`)} -function agentFromV2(project: Project, item: ProjectAgent, spec?: AgentSpec, presets: WorkspacePreset[] = []): AgentDefinition { - const enabled = spec ? spec.status !== AgentStatus.DISABLED : item.enabled; - const resolvedWorkspace = resolveAgentWorkspace(project, spec, presets); - const workspace = resolvedWorkspace.workspace; - const workspaceId = resolvedWorkspace.preset?.id ?? resolvedWorkspace.referenceName; - const projectId = project.summary?.projectId ?? ''; - const completeSpecProjection = Boolean(spec && item.managedAgentId.trim() && item.agentName.trim() && item.provider.trim()); - const canonicalSpecMisread = completeSpecProjection && item.availability === ProjectAgentAvailability.VALIDATION_FAILED; - const availability = canonicalSpecMisread - ? (enabled ? ProjectAgentAvailability.AVAILABLE : ProjectAgentAvailability.UNAVAILABLE) - : (enabled ? item.availability : ProjectAgentAvailability.UNAVAILABLE); - const health = canonicalSpecMisread && item.latestRun?.status !== RunStatus.FAILED ? ProjectAgentHealth.HEALTHY : item.health; - const displayName = item.displayName.trim() || spec?.displayName.trim() || item.agentName; - const description = item.description.trim() || spec?.description.trim() || ''; - const workspaceSource = workspace?.provider.trim().toLowerCase() === 'git' ? 'git' : workspace ? 'file' : 'empty'; - return { - id: agentIdFromV2(projectId, item), - agentName: item.agentName, - name: displayName, - description, - enabled, - provider: item.provider || 'codex', - model: item.model, - systemPrompt: spec?.systemPrompt ?? '', - runtimeImageId: '', - driver: item.driver, - guestImage: item.image, - workspaceId, - envItems: (spec?.env ?? []).map((value) => ({ name: value.name, value: value.value, secret: value.secret })), - configJson: '{}', - capsetIds: spec?.capsetIds ?? [], - availability: availability === ProjectAgentAvailability.AVAILABLE ? '可用' : availability === ProjectAgentAvailability.UNAVAILABLE ? '不可用' : availability === ProjectAgentAvailability.VALIDATION_FAILED ? '校验失败' : '未知', - availabilityClass: availability === ProjectAgentAvailability.AVAILABLE ? 'green' : availability === ProjectAgentAvailability.UNAVAILABLE ? 'amber' : 'red', - health: health === ProjectAgentHealth.HEALTHY ? '健康' : health === ProjectAgentHealth.AT_RISK ? '有风险' : '未知', - healthClass: health === ProjectAgentHealth.HEALTHY ? 'green' : health === ProjectAgentHealth.AT_RISK ? 'amber' : 'red', - workFiles: { - source: workspaceSource, - workspaceId, - workspaceName: resolvedWorkspace.referenceName, - workspaceType: workspace?.provider ?? '', - summary: workspace?.path ?? '', - configJson: '', - }, - currentRun: { - text: item.currentRun?.text ?? '暂无运行', - runningSessionCount: item.currentRun?.runningRunCount ?? 0, - runningLoaderRunCount: item.currentRun?.runningSchedulerRunCount ?? 0, - }, - latestRun: item.latestRun ? { - runType: item.latestRun.source === RunSource.SCHEDULER ? 'scheduler' : 'manual', - status: RunStatus[item.latestRun.status] ?? '', - runId: item.latestRun.runId, - title: '', - at: timestampString(item.latestRun.at), - } : null, - createdAt: '', - updatedAt: '', - deletedAt: '', - projectId, - }; -} -function agentIdFromV2(projectId:string,item:ProjectAgent):string{return item.managedAgentId.trim()||`project:${encodeURIComponent(projectId)}:agent:${encodeURIComponent(item.agentName)}`} -function parseAgentFallbackId(id:string):{projectId:string;agentName:string}|undefined{const match=/^project:([^:]+):agent:(.+)$/.exec(id);return match?{projectId:decodeURIComponent(match[1]),agentName:decodeURIComponent(match[2])}:undefined} -function timestampString(value?:{seconds:bigint;nanos:number}){return value?new Date(Number(value.seconds)*1000+value.nanos/1e6).toISOString():''} diff --git a/src/api/auth.ts b/src/api/auth.ts deleted file mode 100644 index 11c48b5..0000000 --- a/src/api/auth.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { apiFetchJson } from './http'; - -// Login/auth state from the agent-compose-ui server. Auth is enabled via the -// UI-side AUTH_* / OAUTH_* env at deploy time; there is no RPC to change it, -// so the settings UI shows this read-only. -export type AuthStatus = { - enabled: boolean; - loggedIn: boolean; - oauthEnabled: boolean; - username: string; - expiresAt: string; -}; - -export async function getAuthStatus(): Promise { - const response = await apiFetchJson<{ - enabled?: boolean; - loggedIn?: boolean; - oauthEnabled?: boolean; - username?: string; - expiresAt?: string; - }>('/api/auth/status'); - return { - enabled: Boolean(response.enabled), - loggedIn: Boolean(response.loggedIn), - oauthEnabled: Boolean(response.oauthEnabled), - username: response.username ?? '', - expiresAt: response.expiresAt ?? '', - }; -} - -export async function loginWithPassword(username: string, password: string): Promise { - const response = await apiFetchJson<{ - enabled?: boolean; - loggedIn?: boolean; - oauthEnabled?: boolean; - username?: string; - expiresAt?: string; - }>('/api/auth/login', { - method: 'POST', - body: JSON.stringify({ username, password }), - }); - return { - enabled: Boolean(response.enabled), - loggedIn: Boolean(response.loggedIn), - oauthEnabled: Boolean(response.oauthEnabled), - username: response.username ?? '', - expiresAt: response.expiresAt ?? '', - }; -} diff --git a/src/api/client.ts b/src/api/client.ts deleted file mode 100644 index 43e36cd..0000000 --- a/src/api/client.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createClient } from '@connectrpc/connect'; -import { createGrpcWebTransport } from '@connectrpc/connect-web'; - -import { - CapabilityService, - DashboardService, - ExecService, - ProjectService, - RunService, - SandboxService, - SettingsService, -} from '../gen/agentcompose/v2/agentcompose_connect.js'; -import { HealthService } from '../gen/health/v1/health_connect.js'; -import { connectBaseUrl } from '../paths'; - -const grpcWebTransport = createGrpcWebTransport({ - baseUrl: connectBaseUrl(), -}); - -export const projectClient = createClient(ProjectService, grpcWebTransport); -export const sandboxClient = createClient(SandboxService, grpcWebTransport); -export const settingsClient = createClient(SettingsService, grpcWebTransport); -export const capabilityClient = createClient(CapabilityService, grpcWebTransport); -export const dashboardClient = createClient(DashboardService, grpcWebTransport); -export const healthClient = createClient(HealthService, grpcWebTransport); -export const runClient = createClient(RunService, grpcWebTransport); -export const execClient = createClient(ExecService, grpcWebTransport); diff --git a/src/api/config.ts b/src/api/config.ts deleted file mode 100644 index 46d4629..0000000 --- a/src/api/config.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { capabilityClient, settingsClient } from './client'; -import { apiFetch, apiFetchJson } from './http'; - -export type CapabilityGatewayConfig = { - addr: string; - tokenSet: boolean; -}; - -export type CapabilityStatus = { - configured: boolean; - ok: boolean; - status: string; - serviceCount: number; - error: string; - runtimeConfigured: boolean; - proxyListenConfigured: boolean; - proxyTargetConfigured: boolean; -}; - -export async function getCapabilityGatewayConfig(): Promise { - const response = await settingsClient.getCapabilityGatewayConfig({}); - return { addr: response.config?.addr ?? '', tokenSet: response.config?.tokenSet ?? false }; -} - -// updateCapabilityGatewayConfig saves the OctoBus connection. An empty token -// clears the stored token. -export async function updateCapabilityGatewayConfig(addr: string, token: string): Promise { - const response = await settingsClient.updateCapabilityGatewayConfig({ addr, token }); - return { addr: response.config?.addr ?? '', tokenSet: response.config?.tokenSet ?? false }; -} - -export async function getCapabilityStatus(): Promise { - const response = await capabilityClient.getCapabilityStatus({}); - return { - configured: response.configured, - ok: response.ok, - status: response.status, - serviceCount: response.serviceCount, - error: response.error, - runtimeConfigured: response.runtimeConfigured, - proxyListenConfigured: response.proxyListenConfigured, - proxyTargetConfigured: response.proxyTargetConfigured, - }; -} - -export type CapabilitySet = { - id: string; - name: string; - description: string; - enabled: boolean; -}; - -export async function listCapabilitySets(): Promise { - const response = await capabilityClient.listCapabilitySets({}); - return response.capsets.map((item) => ({ - id: item.id, - name: item.name, - description: item.description, - enabled: item.enabled, - })); -} - -export type CapabilityMethodInfo = { - methodFullName: string; - serviceId: string; - instanceId: string; - backendInstanceStatus: string; -}; - -export async function getCapabilityCatalog(capsetId: string): Promise { - const response = await capabilityClient.getCapabilityCatalog({ capsetId }); - return response.methods.map((method) => ({ - methodFullName: method.methodFullName, - serviceId: method.serviceId, - instanceId: method.instanceId, - backendInstanceStatus: method.backendInstanceStatus, - })); -} - -export type EnvItem = { - name: string; - value: string; - secret: boolean; - valueKnown: boolean; -}; - -export type WorkspaceFileEntry = { - path: string; - dir: boolean; - size: number; - updatedAt: string; -}; - -export type WorkspacePreset = { - id: string; - name: string; - type: string; - configJson: string; - comment: string; - createdAt: string; - updatedAt: string; -}; - -export type WorkspacePresetInput = { - name: string; - type: string; - configJson: string; - comment: string; -}; - -export type WebhookSource = { - id: string; - name: string; - enabled: boolean; - provider: string; - topicPrefix: string; - hasToken: boolean; - signatureType: string; - hasSignatureSecret: boolean; - bodyLimitBytes: number; - createdAt: string; - updatedAt: string; -}; - -export type WebhookSourceInput = { - name: string; - enabled: boolean; - provider: string; - topicPrefix: string; - token: string; - clearToken: boolean; - signatureType: string; - signatureSecret: string; - clearSignature: boolean; - bodyLimitBytes: number; -}; - -type WebhookSourceResponseItem = { - id: string; - name: string; - enabled: boolean; - provider: string; - topic_prefix: string; - has_token: boolean; - signature_type?: string; - has_signature_secret?: boolean; - body_limit_bytes: number; - created_at: string; - updated_at: string; -}; - -type WebhookSourceListResponse = { - items?: WebhookSourceResponseItem[]; -}; - -type WebhookSourceResponse = { - source?: WebhookSourceResponseItem; -}; - -export async function listEnvItems(): Promise { - const response = await settingsClient.getGlobalEnv({}); - return response.env.map((item) => ({ - name: item.name, - value: item.secret && item.value ? '' : item.value, - secret: item.secret, - valueKnown: !item.secret || !item.value, - })); -} - -export async function updateEnvItems(envItems: EnvItem[]): Promise { - await settingsClient.updateGlobalEnv({ - env: envItems - .filter((item) => item.name.trim()) - .map((item) => ({ - name: item.name.trim(), - ...(item.valueKnown ? { value: item.value } : {}), - secret: item.secret, - })), - }); -} - -export async function listWorkspacePresets(): Promise { - const response = await settingsClient.listWorkspacePresets({}); - return response.presets.map((item) => ({ - id: item.id, - name: item.name, - type: item.type, - configJson: item.configJson, - comment: item.comment, - createdAt: timestampString(item.createdAt), - updatedAt: timestampString(item.updatedAt), - })); -} - -export async function createWorkspacePreset(input: WorkspacePresetInput): Promise { - const response = await settingsClient.createWorkspacePreset({ - name: input.name.trim(), - type: input.type, - configJson: input.configJson, - comment: input.comment.trim(), - }); - if (!response.preset) { - throw new Error('Workspace 配置保存失败'); - } - return workspaceFromResponse(response.preset); -} - -export async function updateWorkspacePreset(id: string, input: WorkspacePresetInput): Promise { - const response = await settingsClient.updateWorkspacePreset({ - presetId: id, - name: input.name.trim(), - type: input.type, - configJson: input.configJson, - comment: input.comment.trim(), - }); - if (!response.preset) { - throw new Error('Workspace 配置保存失败'); - } - return workspaceFromResponse(response.preset); -} - -export async function deleteWorkspacePreset(id: string): Promise { - await settingsClient.deleteWorkspacePreset({ presetId: id }); -} - -type WorkspaceFilesResponse = { - files?: Array<{ path: string; dir: boolean; size: number; updated_at: string }>; -}; - -function workspaceFilesFromResponse(response: WorkspaceFilesResponse): WorkspaceFileEntry[] { - return (response.files ?? []).map((item) => ({ - path: item.path, - dir: item.dir, - size: item.size, - updatedAt: item.updated_at, - })); -} - -export async function listWorkspaceFiles(workspaceId: string): Promise { - const response = await apiFetchJson( - `/api/agent-compose/workspaces/${encodeURIComponent(workspaceId)}/files`, - ); - return workspaceFilesFromResponse(response); -} - -export async function uploadWorkspaceArchive(workspaceId: string, file: File): Promise { - const formData = new FormData(); - formData.set('upload_type', 'archive'); - formData.set('file', file); - const response = await apiFetchJson( - `/api/agent-compose/workspaces/${encodeURIComponent(workspaceId)}/upload`, - { method: 'POST', body: formData }, - ); - return workspaceFilesFromResponse(response); -} - -export async function listWebhookSources(): Promise { - const response = await apiFetchJson('/api/webhook-sources'); - return (response.items ?? []).map(webhookSourceFromResponse); -} - -export async function saveWebhookSource(id: string, input: WebhookSourceInput): Promise { - const response = await apiFetchJson( - `/api/webhook-sources/${encodeURIComponent(id)}`, - { - method: 'PUT', - body: JSON.stringify({ - name: input.name.trim(), - enabled: input.enabled, - provider: input.provider.trim(), - topic_prefix: input.topicPrefix.trim(), - token: input.token, - clear_token: input.clearToken, - signature_type: input.signatureType.trim(), - signature_secret: input.signatureSecret, - clear_signature: input.clearSignature, - body_limit_bytes: input.bodyLimitBytes, - }), - }, - ); - if (!response.source) { - throw new Error('Webhook 来源保存失败'); - } - return webhookSourceFromResponse(response.source); -} - -export async function deleteWebhookSource(id: string): Promise { - await apiFetch(`/api/webhook-sources/${encodeURIComponent(id)}`, { method: 'DELETE' }); -} - -function workspaceFromResponse(item: NonNullable>['preset']>): WorkspacePreset { - return { - id: item.id, - name: item.name, - type: item.type, - configJson: item.configJson, - comment: item.comment, - createdAt: timestampString(item.createdAt), - updatedAt: timestampString(item.updatedAt), - }; -} - -function timestampString(value?: { seconds: bigint; nanos: number }): string { - return value ? new Date(Number(value.seconds) * 1000 + value.nanos / 1e6).toISOString() : ''; -} - -function webhookSourceFromResponse(item: WebhookSourceResponseItem): WebhookSource { - return { - id: item.id, - name: item.name, - enabled: item.enabled, - provider: item.provider, - topicPrefix: item.topic_prefix, - hasToken: item.has_token, - signatureType: item.signature_type ?? '', - hasSignatureSecret: item.has_signature_secret ?? false, - bodyLimitBytes: item.body_limit_bytes, - createdAt: item.created_at, - updatedAt: item.updated_at, - }; -} diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts deleted file mode 100644 index 38e5331..0000000 --- a/src/api/dashboard.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { dashboardClient } from './client'; - -export type DashboardOverview = { - runningCount: number; - recentCount: number; - attentionCount: number; - updatedAt: string; -}; - -export async function getDashboardOverview(signal?: AbortSignal): Promise { - const resp = await dashboardClient.getDashboardOverview({}, { signal }); - return toDashboardOverview(resp.overview); -} - -export async function watchDashboardOverview( - onOverview: (overview: DashboardOverview, reason: string) => void, - signal?: AbortSignal, -): Promise { - const stream = dashboardClient.watchDashboardOverview({}, { signal }); - for await (const event of stream) { - onOverview(toDashboardOverview(event.overview), event.reason); - } -} - -function toDashboardOverview(overview?: { - runs?: { runningCount?: number; recentCount?: number; attentionCount?: number }; - updatedAt?: { seconds?: bigint; nanos?: number }; -}): DashboardOverview { - return { - runningCount: Number(overview?.runs?.runningCount ?? 0), - recentCount: Number(overview?.runs?.recentCount ?? 0), - attentionCount: Number(overview?.runs?.attentionCount ?? 0), - updatedAt: overview?.updatedAt ? new Date(Number(overview.updatedAt.seconds ?? 0n) * 1000 + Number(overview.updatedAt.nanos ?? 0) / 1e6).toISOString() : '', - }; -} diff --git a/src/api/exec.ts b/src/api/exec.ts deleted file mode 100644 index 4d42bcd..0000000 --- a/src/api/exec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ExecStreamEventType } from '../gen/agentcompose/v2/agentcompose_pb.js'; - -import { execClient } from './client'; - -export type RuntimeExecInput = { - sandboxId?: string; - runId?: string; - command: string; - cwd?: string; - timeoutMs?: number; -}; - -export type RuntimeExecResult = { - execId: string; - sandboxId: string; - runId: string; - command: string; - cwd: string; - exitCode: number; - success: boolean; - stdout: string; - stderr: string; - output: string; - error: string; -}; - -export type RuntimeExecEvent = - | { type: 'started'; execId: string; sandboxId: string; runId: string } - | { type: 'chunk'; execId: string; sandboxId: string; runId: string; chunk: string; isStderr: boolean } - | { type: 'completed'; execId: string; sandboxId: string; runId: string; result: RuntimeExecResult | null }; - -export async function executeRuntimeCommandStream( - input: RuntimeExecInput, - onEvent: (event: RuntimeExecEvent) => void, - signal?: AbortSignal, -): Promise { - const source = input.command.trim(); - if (!source) { - throw new Error('命令不能为空'); - } - const sandboxId = input.sandboxId?.trim() || ''; - const runId = input.runId?.trim() || ''; - if (!sandboxId && !runId) { - throw new Error('缺少可执行命令的运行会话'); - } - - let finalResult: RuntimeExecResult | null = null; - const request = { - target: sandboxId - ? { case: 'sandboxId' as const, value: sandboxId } - : { case: 'runId' as const, value: runId }, - command: { - command: 'sh', - args: ['-lc', source], - }, - cwd: input.cwd?.trim() || '', - timeoutMs: input.timeoutMs ?? 0, - }; - - for await (const event of execClient.execStream(request, { signal })) { - if (event.eventType === ExecStreamEventType.STARTED) { - onEvent({ - type: 'started', - execId: event.execId, - sandboxId: event.sandboxId, - runId: event.runId, - }); - } else if (event.eventType === ExecStreamEventType.OUTPUT) { - onEvent({ - type: 'chunk', - execId: event.execId, - sandboxId: event.sandboxId, - runId: event.runId, - chunk: event.chunk, - isStderr: event.stream === 2, - }); - } else if (event.eventType === ExecStreamEventType.COMPLETED) { - finalResult = event.result ? resultFromProto(event.result) : null; - onEvent({ - type: 'completed', - execId: event.execId, - sandboxId: event.sandboxId, - runId: event.runId, - result: finalResult, - }); - } - } - return finalResult; -} - -function resultFromProto(item: { - execId: string; - sandboxId: string; - runId: string; - command?: { command: string; args: string[] }; - cwd: string; - exitCode: number; - success: boolean; - stdout: string; - stderr: string; - output: string; - error: string; -}): RuntimeExecResult { - return { - execId: item.execId, - sandboxId: item.sandboxId, - runId: item.runId, - command: item.command ? [item.command.command, ...item.command.args].join(' ') : '', - cwd: item.cwd, - exitCode: item.exitCode, - success: item.success, - stdout: item.stdout, - stderr: item.stderr, - output: item.output, - error: item.error, - }; -} diff --git a/src/api/health.ts b/src/api/health.ts deleted file mode 100644 index bb211c8..0000000 --- a/src/api/health.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { healthClient } from './client'; - -export type HealthStatus = { - version: string; - currentTime: string; - startedAt: string; - uptimeSeconds: number; - goVersion: string; - numGoroutines: number; - memorySys: number; - memoryHeapAlloc: number; - processCpuPercent: number; - processCpuUserMillis: number; - processCpuSystemMillis: number; - processRssBytes: number; - processReadBytes: number; - processWriteBytes: number; - processReadOps: number; - processWriteOps: number; -}; - -export async function getHealthStatus(): Promise { - const response = await healthClient.status({}); - return healthStatusFromResponse(response); -} - -export async function watchHealthStatus(onStatus: (status: HealthStatus) => void, signal?: AbortSignal): Promise { - for await (const response of healthClient.watchStatus({}, { signal })) { - onStatus(healthStatusFromResponse(response)); - } -} - -function healthStatusFromResponse(response: Awaited>): HealthStatus { - return { - version: response.version, - currentTime: response.currentTime, - startedAt: response.startedAt, - uptimeSeconds: Number(response.uptimeSeconds), - goVersion: response.goVersion, - numGoroutines: Number(response.numGoroutines), - memorySys: Number(response.memory?.sys ?? 0), - memoryHeapAlloc: Number(response.memory?.heapAlloc ?? 0), - processCpuPercent: response.process?.cpuPercent ?? 0, - processCpuUserMillis: Number(response.process?.cpuUserMillis ?? 0), - processCpuSystemMillis: Number(response.process?.cpuSystemMillis ?? 0), - processRssBytes: Number(response.process?.rssBytes ?? 0), - processReadBytes: Number(response.process?.readBytes ?? 0), - processWriteBytes: Number(response.process?.writeBytes ?? 0), - processReadOps: Number(response.process?.readOps ?? 0), - processWriteOps: Number(response.process?.writeOps ?? 0), - }; -} diff --git a/src/api/http.ts b/src/api/http.ts deleted file mode 100644 index 2de9293..0000000 --- a/src/api/http.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Generic helper for the backend's plain HTTP endpoints (non-Connect), -// e.g. /api/auth/* and /api/agent-compose/workspaces/*. Connect RPCs use client.ts. - -import { apiPath } from '../paths'; - -export async function apiFetch(path: string, init: RequestInit = {}): Promise { - const isFormData = typeof FormData !== 'undefined' && init.body instanceof FormData; - const headers: Record = { ...((init.headers as Record | undefined) ?? {}) }; - if (!isFormData && init.body !== undefined && headers['content-type'] === undefined) { - headers['content-type'] = 'application/json'; - } - const response = await fetch(apiPath(path), { credentials: 'same-origin', ...init, headers }); - if (!response.ok) { - const text = await response.text().catch(() => ''); - let messageText = text; - try { - messageText = (JSON.parse(text) as { error?: string })?.error ?? text; - } catch { - // body was not JSON; keep raw text - } - throw new Error(messageText || `请求失败:${response.status}`); - } - return response; -} - -export async function apiFetchJson(path: string, init: RequestInit = {}): Promise { - const response = await apiFetch(path, init); - return (await response.json()) as T; -} diff --git a/src/api/loaders.ts b/src/api/loaders.ts deleted file mode 100644 index 57f69f0..0000000 --- a/src/api/loaders.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { apiFetchJson } from './http'; -import { projectClient } from './client'; -import { EventTriggerSpec, ProjectValidationSeverity, SchedulerRunStatus, TriggerSpec, type Project, type ResolvedTrigger, type SchedulerRun, type SchedulerSummary } from '../gen/agentcompose/v2/agentcompose_pb.js'; -import { toLegacySessionPolicy, toProjectSandboxPolicy, type LegacySessionPolicy } from '../model/sandbox-policy'; - -export type AutomationTask = { - id: string; - name: string; - description: string; - enabled: boolean; - runtime: string; - workspaceId: string; - agentId: string; - agentName: string; - capsetIds: string[]; - defaultAgent: string; - triggerCount: number; - runCount: number; - eventCount: number; - latestRunAt: string; - lastError: string; - createdAt: string; - updatedAt: string; - driver: string; - guestImage: string; - sessionPolicy: LegacySessionPolicy; - concurrencyPolicy: string; -}; - -export type AutomationTrigger = { - loaderId: string; - triggerId: string; - kind: string; - topic: string; - intervalMs: number; - enabled: boolean; - autoId: boolean; - specJson: string; - nextFireAt: string; - lastFiredAt: string; - name: string; - prompt: string; -}; - -export type AutomationTaskDetail = AutomationTask & { - script: string; - triggers: AutomationTrigger[]; - envItems: Array<{ name: string; value: string; secret: boolean }>; -}; - -export type SaveAutomationTaskInput = { - id?: string; - name: string; - description: string; - runtime: string; - script: string; - workspaceId: string; - driver: string; - guestImage: string; - agentId: string; - capsetIds: string[]; - defaultAgent: string; - sessionPolicy: LegacySessionPolicy; - concurrencyPolicy: string; - enabled: boolean; - envItems?: Array<{ name: string; value: string; secret: boolean }>; - triggers?: AutomationTriggerInput[]; -}; - -export type AutomationTriggerInput = { - name: string; - kind: string; - cron?: string; - interval?: string; - timeout?: string; - topic?: string; - prompt?: string; - sandboxPolicy?: string; -}; - -export type ValidateAutomationTaskResult = { - triggers: AutomationTrigger[]; - warnings: string[]; -}; - -export type AutomationRun = { - id: string; - loaderId: string; - triggerId: string; - triggerKind: string; - triggerSource: string; - status: string; - startedAt: string; - completedAt: string; - durationMs: number; - error: string; - resultJson: string; - payloadJson: string; - artifactsDir: string; -}; - -export type AutomationEvent = { - id: string; - loaderId: string; - runId: string; - triggerId: string; - type: string; - level: string; - message: string; - payloadJson: string; - linkedSessionId: string; - linkedCellId: string; - linkedAgentSessionId: string; - createdAt: string; - topicEventId: string; -}; - -export type TopicEvent = { - eventId: string; - sequence: number; - topic: string; - source: string; - provider: string; - intent: string; - correlationId: string; - idempotencyKey: string; - deliveryId: string; - dispatchStatus: string; - parentEventId: string; - publisherType: string; - publisherId: string; - publisherRunId: string; - createdAt: string; - dispatchedAt: string; - payload: Record; -}; - -export type TopicEventRun = { - eventId: string; - loaderId: string; - runId: string; - triggerId: string; - status: string; - error: string; - createdAt: string; - updatedAt: string; -}; - -export type TopicEventSession = { - sessionId: string; - relation: string; - loaderId: string; - runId: string; - triggerId: string; - loaderEventId: string; - eventId: string; - createdAt: string; -}; - -export async function listAutomationTasks(): Promise { - return (await listAllSchedulers()).map(taskFromV2); -} - -export async function getAutomationTask(id: string): Promise { - const found = await findScheduler(id); if (!found) throw new Error('自动化任务不存在'); - const response = await projectClient.getScheduler({ project: { projectId: found.projectId }, agentName: found.agentName }); - const summary = taskFromV2(found); - return { - ...summary, - name: response.spec?.displayName.trim() || response.scheduler?.displayName.trim() || summary.name, - description: response.spec?.description.trim() || response.scheduler?.description.trim() || summary.description, - sessionPolicy: toLegacySessionPolicy(response.spec?.sandboxPolicy || summary.sessionPolicy), - script: response.spec?.script ?? '', - triggers: response.triggers.map(triggerFromResolved), - envItems: [], - }; -} - -export async function resolveAutomationSessionTarget(id:string):Promise<{projectId:string;agentName:string}|undefined>{const found=await findScheduler(id);return found?{projectId:found.projectId,agentName:found.agentName}:undefined} - -export async function saveAutomationTask(input: SaveAutomationTaskInput): Promise { - const target = input.id ? await findScheduler(input.id) : await findProjectAgent(input.agentId || input.defaultAgent); if (!target) throw new Error('自动化任务必须关联项目智能体'); - const project = await loadProject(target.projectId); const agents = (project.spec?.agents ?? []).map((agent) => agent.name === target.agentName ? { ...agent, scheduler: { enabled: input.enabled, displayName: input.name.trim(), description: input.description.trim(), script: input.script, sandboxPolicy: toProjectSandboxPolicy(input.sessionPolicy), triggers: (input.triggers ?? []).map(triggerSpecFromInput) } } : agent); - await projectClient.applyProject({ spec: { ...project.spec!, agents } }); - const refreshed = await findSchedulerByAgent(target.projectId, target.agentName); if (!refreshed) throw new Error('自动化任务保存失败'); return getAutomationTask(refreshed.schedulerId); -} - -export async function deleteAutomationTask(id: string): Promise { - const found=await findScheduler(id);if(!found)return;const project=await loadProject(found.projectId);const agents=(project.spec?.agents??[]).map((agent)=>agent.name===found.agentName?{...agent,scheduler:undefined}:agent);await projectClient.applyProject({spec:{...project.spec!,agents}}); -} - -export async function setAutomationTaskEnabled(id: string, enabled: boolean): Promise { - const found=await findScheduler(id);if(!found)throw new Error('自动化任务不存在');await projectClient.setSchedulerEnabled({project:{projectId:found.projectId},agentName:found.agentName,enabled});return{...taskFromV2(found),enabled}; -} - -export async function setAutomationTriggerEnabled(loaderId: string, triggerId: string, enabled: boolean): Promise { - const found=await findScheduler(loaderId);if(!found)throw new Error('自动化任务不存在');await projectClient.setSchedulerTriggerEnabled({project:{projectId:found.projectId},agentName:found.agentName,triggerId,enabled});return getAutomationTask(loaderId); -} - -export async function validateAutomationTask(script: string, runtime: string): Promise { - if (runtime !== 'scheduler') throw new Error(`不支持的自动化运行时:${runtime}`); - const response = await projectClient.validateProject({ - spec: { - name: 'ui-automation-validation', - agents: [{ - name: 'automation', - provider: 'codex', - scheduler: { enabled: true, script, sandboxPolicy: 'sticky', triggers: [] }, - }], - }, - }); - const errors = response.issues.filter((issue) => issue.severity === ProjectValidationSeverity.ERROR); - if (!response.valid || errors.length > 0) { - const details = (errors.length > 0 ? errors : response.issues) - .map((issue) => issue.path ? `${issue.path}: ${issue.message}` : issue.message) - .filter(Boolean) - .join('\n'); - throw new Error(details || '自动化脚本校验失败'); - } - return { - triggers: [], - warnings: response.issues - .filter((issue) => issue.severity === ProjectValidationSeverity.WARNING) - .map((issue) => issue.path ? `${issue.path}: ${issue.message}` : issue.message), - }; -} - -export async function runAutomationTaskNow(loaderId: string, payloadJson: string, triggerId = ''): Promise { - const scheduler = await requireScheduler(loaderId); - const response = await projectClient.runScheduler({ - project: { projectId: scheduler.projectId }, - agentName: scheduler.agentName, - triggerId, - payloadJson, - }); - if (!response.run) throw new Error('自动化任务运行失败'); - return runFromScheduler(response.run); -} - -export async function getAutomationRun(loaderId: string, runId: string): Promise { - const scheduler = await requireScheduler(loaderId); - const response = await projectClient.getSchedulerRun({ project: { projectId: scheduler.projectId }, runId }); - if (!response.run) throw new Error('自动化运行不存在'); - return runFromScheduler(response.run); -} - -export async function listRecentAutomationRuns(loaderIds: string[], limit = 10): Promise { - if (loaderIds.length === 0 || limit <= 0) return []; - const requestedIds = new Set(loaderIds); - const schedulers = (await listAllSchedulers()).filter((item) => requestedIds.has(item.schedulerId)); - const responses = await Promise.all(schedulers.map((scheduler) => projectClient.listSchedulerRuns({ - project: { projectId: scheduler.projectId }, - agentName: scheduler.agentName, - limit: Math.min(Math.ceil(limit), 500), - }))); - const uniqueRuns = new Map(); - for (const response of responses) { - for (const run of response.runs) { - uniqueRuns.set(run.runId, run); - } - } - return [...uniqueRuns.values()] - .map(runFromScheduler) - .sort((left, right) => compareDateDesc(left.startedAt, right.startedAt)) - .slice(0, limit); -} - -export async function stopAutomationRun(loaderId: string, runId: string, reason = ''): Promise<{ run: AutomationRun; stopRequested: boolean }> { - const scheduler = await requireScheduler(loaderId); - const response = await projectClient.stopSchedulerRun({ - project: { projectId: scheduler.projectId }, - runId, - reason, - }); - if (!response.run) throw new Error('自动化运行不存在'); - return { run: runFromScheduler(response.run), stopRequested: response.stopRequested }; -} - -export async function listAutomationEvents(loaderId: string, limit = 50): Promise { - const found=await findScheduler(loaderId);if(!found)return[];const response = await projectClient.listSchedulerEvents({ project:{projectId:found.projectId},agentName:found.agentName,limit }); - return response.events.map((item) => ({ - id: item.id, - loaderId, - runId: item.runId, - triggerId: item.triggerId, - type: item.type, - level: item.level, - message: item.message, - payloadJson: item.payloadJson, - linkedSessionId: '', linkedCellId: '', linkedAgentSessionId: '', - createdAt: timestampString(item.createdAt), - topicEventId: topicEventIdFromPayload(item.payloadJson), - })); -} - -async function listAllSchedulers():Promise{const result:SchedulerSummary[]=[];let token='';do{const response=await projectClient.listSchedulers({limit:500,cursor:token});result.push(...response.schedulers);token=response.nextCursor}while(token);return result} -async function findScheduler(id:string):Promise{return (await listAllSchedulers()).find((value)=>value.schedulerId===id)} -async function requireScheduler(id: string): Promise { - const scheduler = await findScheduler(id); - if (!scheduler) throw new Error('自动化任务不存在'); - return scheduler; -} -async function findSchedulerByAgent(projectId:string,agentName:string){return (await listAllSchedulers()).find((value)=>value.projectId===projectId&&value.agentName===agentName)} -async function loadProject(projectId:string):Promise{const response=await projectClient.getProject({project:{projectId},includeSpec:true});if(!response.project)throw new Error('项目不存在');return response.project} -async function findProjectAgent(id:string):Promise<{projectId:string;agentName:string}|undefined>{let offset=0;for(;;){const listed=await projectClient.listProjects({limit:200,offset});for(const summary of listed.projects){const project=await loadProject(summary.projectId);const agent=project.agents.find((value)=>value.managedAgentId===id||value.agentName===id);if(agent)return{projectId:summary.projectId,agentName:agent.agentName}}if(!listed.hasMore)return undefined;offset=listed.nextOffset}} -function taskFromV2(item:SchedulerSummary):AutomationTask{return{id:item.schedulerId,name:item.displayName.trim()||item.agentName,description:item.description.trim(),enabled:item.enabled,runtime:'scheduler',workspaceId:'',agentId:item.agentName,agentName:item.agentName,capsetIds:[],defaultAgent:'',triggerCount:item.triggerCount,runCount:item.runCount,eventCount:0,latestRunAt:timestampString(item.latestRunAt),lastError:item.lastError,createdAt:'',updatedAt:'',driver:'',guestImage:'',sessionPolicy:'new_session',concurrencyPolicy:'skip'}} -function triggerSpecFromInput(input:AutomationTriggerInput):TriggerSpec{const sandboxPolicy=input.sandboxPolicy?.trim()??'';return new TriggerSpec({name:input.name.trim(),kind:input.kind.trim(),cron:input.cron?.trim()??'',interval:input.interval?.trim()??'',timeout:input.timeout?.trim()??'',event:input.kind.trim()==='event'?new EventTriggerSpec({topic:input.topic?.trim()??''}):undefined,prompt:input.prompt?.trim()??'',sandboxPolicy:sandboxPolicy?toProjectSandboxPolicy(sandboxPolicy):''})} -function triggerFromResolved(item:ResolvedTrigger):AutomationTrigger{const spec=item.spec;const duration=spec?.interval||spec?.timeout||'';return{loaderId:'',triggerId:item.triggerId,kind:spec?.kind??'',topic:spec?.event?.topic??'',intervalMs:duration?parseDuration(duration):0,enabled:item.enabled,autoId:false,specJson:spec?JSON.stringify(spec.toJson()):'',nextFireAt:timestampString(item.nextFireAt),lastFiredAt:timestampString(item.lastFiredAt),name:spec?.name??'',prompt:spec?.prompt??''}} -function runFromScheduler(item: SchedulerRun): AutomationRun { - return { - id: item.runId, - loaderId: item.schedulerId, - triggerId: item.triggerId, - triggerKind: item.triggerKind, - triggerSource: item.triggerSource, - status: schedulerRunStatus(item.status), - startedAt: timestampString(item.startedAt), - completedAt: timestampString(item.completedAt), - durationMs: Number(item.durationMs), - error: item.error, - resultJson: item.resultJson, - payloadJson: item.payloadJson, - artifactsDir: item.artifactsDir, - }; -} - -function schedulerRunStatus(status: SchedulerRunStatus): string { - switch (status) { - case SchedulerRunStatus.RUNNING: return 'RUNNING'; - case SchedulerRunStatus.SUCCEEDED: return 'SUCCEEDED'; - case SchedulerRunStatus.FAILED: return 'FAILED'; - case SchedulerRunStatus.CANCELED: return 'CANCELED'; - case SchedulerRunStatus.SKIPPED: return 'SKIPPED'; - default: return 'UNSPECIFIED'; - } -} -function timestampString(value?:{seconds:bigint;nanos:number}){return value?new Date(Number(value.seconds)*1000+value.nanos/1e6).toISOString():''} -function parseDuration(value:string){const match=value.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/);if(!match)return 0;const multiplier={ms:1,s:1000,m:60000,h:3600000}[match[2] as 'ms'|'s'|'m'|'h'];return Number(match[1])*multiplier} - -export async function getTopicEvent(eventId: string): Promise { - const response = await apiFetchJson<{ event: TopicEventResponse }>(`/api/events/${encodeURIComponent(eventId)}`); - return topicEventFromResponse(response.event); -} - -export async function listTopicEventRuns(eventId: string): Promise { - const response = await apiFetchJson<{ runs: TopicEventRunResponse[] }>(`/api/events/${encodeURIComponent(eventId)}/runs`); - return response.runs.map((item) => ({ - eventId: item.event_id, - loaderId: item.loader_id, - runId: item.run_id || '', - triggerId: item.trigger_id, - status: item.status, - error: item.error || '', - createdAt: item.created_at, - updatedAt: item.updated_at, - })); -} - -export async function listTopicEventSessions(eventId: string): Promise { - // Older daemons return sessions/session_id, while newer daemons use - // sandboxes/sandbox_id after the session-to-sandbox rename. - const response = await apiFetchJson(`/api/events/${encodeURIComponent(eventId)}/sessions`); - const items = Array.isArray(response.sessions) - ? response.sessions - : Array.isArray(response.sandboxes) - ? response.sandboxes - : []; - return items.map((item) => ({ - sessionId: item.session_id || item.sandbox_id || '', - relation: item.relation, - loaderId: item.loader_id || '', - runId: item.run_id || '', - triggerId: item.trigger_id || '', - loaderEventId: item.loader_event_id || '', - eventId: item.event_id, - createdAt: item.created_at, - })); -} - -function taskFromSummary(item: { - loaderId: string; - name: string; - description: string; - enabled: boolean; - runtime: string; - workspaceId: string; - agentId: string; - capsetIds: string[]; - driver: string; - guestImage: string; - defaultAgent: string; - sessionPolicy: string; - concurrencyPolicy: string; - createdAt: string; - updatedAt: string; - lastError: string; - triggerCount: number; - runCount: number; - eventCount: number; - latestRunAt: string; -}): AutomationTask { - return { - id: item.loaderId, - name: item.name, - description: item.description, - enabled: item.enabled, - runtime: item.runtime, - workspaceId: item.workspaceId, - agentId: item.agentId, - agentName: item.agentId, - capsetIds: item.capsetIds, - defaultAgent: item.defaultAgent, - triggerCount: Number(item.triggerCount), - runCount: Number(item.runCount), - eventCount: Number(item.eventCount), - latestRunAt: item.latestRunAt, - lastError: item.lastError, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - driver: item.driver, - guestImage: item.guestImage, - sessionPolicy: toLegacySessionPolicy(item.sessionPolicy), - concurrencyPolicy: item.concurrencyPolicy, - }; -} - -function triggerFromResponse(item: { - loaderId: string; - triggerId: string; - kind: unknown; - topic: string; - intervalMs: bigint | number | string; - enabled: boolean; - autoId: boolean; - specJson: string; - nextFireAt: string; - lastFiredAt: string; -}): AutomationTrigger { - return { - loaderId: item.loaderId, - triggerId: item.triggerId, - kind: String(item.kind), - topic: item.topic, - intervalMs: Number(item.intervalMs), - enabled: item.enabled, - autoId: item.autoId, - specJson: item.specJson, - nextFireAt: item.nextFireAt, - lastFiredAt: item.lastFiredAt, - }; -} - -function runFromSummary(item: { - runId: string; - loaderId: string; - triggerId: string; - triggerKind: unknown; - triggerSource: string; - status: string; - startedAt: string; - completedAt: string; - durationMs: bigint | number | string; - error: string; - resultJson: string; - payloadJson: string; - artifactsDir: string; -}): AutomationRun { - return { - id: item.runId, - loaderId: item.loaderId, - triggerId: item.triggerId, - triggerKind: String(item.triggerKind), - triggerSource: item.triggerSource, - status: item.status, - startedAt: item.startedAt, - completedAt: item.completedAt, - durationMs: Number(item.durationMs), - error: item.error, - resultJson: item.resultJson, - payloadJson: item.payloadJson, - artifactsDir: item.artifactsDir, - }; -} - -function topicEventIdFromPayload(raw: string): string { - if (!raw.trim()) { - return ''; - } - try { - const parsed = JSON.parse(raw) as Record; - const direct = parsed.eventId ?? parsed.event_id; - if (typeof direct === 'string') { - return direct; - } - const event = parsed.event; - if (event && typeof event === 'object') { - const nested = (event as Record).eventId ?? (event as Record).event_id; - return typeof nested === 'string' ? nested : ''; - } - } catch { - return ''; - } - return ''; -} - -type TopicEventResponse = { - event_id: string; - sequence: number; - topic: string; - source: string; - provider?: string; - intent?: string; - correlation_id: string; - idempotency_key?: string; - delivery_id?: string; - dispatch_status: string; - parent_event_id?: string; - publisher_type?: string; - publisher_id?: string; - publisher_run_id?: string; - created_at: string; - dispatched_at?: string; - payload?: Record; -}; - -type TopicEventRunResponse = { - event_id: string; - loader_id: string; - run_id?: string; - trigger_id: string; - status: string; - error?: string; - created_at: string; - updated_at: string; -}; - -type TopicEventSessionResponse = { - session_id?: string; - sandbox_id?: string; - relation: string; - loader_id?: string; - run_id?: string; - trigger_id?: string; - loader_event_id?: string; - event_id: string; - created_at: string; -}; - -type TopicEventSessionsResponse = { - sessions?: TopicEventSessionResponse[]; - sandboxes?: TopicEventSessionResponse[]; -}; - -function topicEventFromResponse(item: TopicEventResponse): TopicEvent { - return { - eventId: item.event_id, - sequence: Number(item.sequence || 0), - topic: item.topic, - source: item.source, - provider: item.provider || '', - intent: item.intent || '', - correlationId: item.correlation_id, - idempotencyKey: item.idempotency_key || '', - deliveryId: item.delivery_id || '', - dispatchStatus: item.dispatch_status, - parentEventId: item.parent_event_id || '', - publisherType: item.publisher_type || '', - publisherId: item.publisher_id || '', - publisherRunId: item.publisher_run_id || '', - createdAt: item.created_at, - dispatchedAt: item.dispatched_at || '', - payload: item.payload || {}, - }; -} - -function compareDateDesc(left: string, right: string): number { - return new Date(right || 0).getTime() - new Date(left || 0).getTime(); -} diff --git a/src/api/runs.ts b/src/api/runs.ts deleted file mode 100644 index 75786db..0000000 --- a/src/api/runs.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { runClient } from './client'; - -export type ProjectRunDebugTarget = { - runId: string; - sandboxId: string; -}; - -export async function getProjectRunDebugTarget(runId: string): Promise { - const response = await runClient.getRun({ runId }); - const summary = response.run?.summary; - if (!summary) { - throw new Error('运行记录不存在'); - } - if (!summary.sandboxId) { - throw new Error('当前运行没有关联的调试会话'); - } - return { - runId: summary.runId, - sandboxId: summary.sandboxId, - }; -} diff --git a/src/api/sessions.ts b/src/api/sessions.ts deleted file mode 100644 index 792fb96..0000000 --- a/src/api/sessions.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { RunAgentStreamEventType, RunEventKind, RunSandboxCleanupPolicy, RunSource, SandboxWatchEventType, type Sandbox } from '../gen/agentcompose/v2/agentcompose_pb.js'; -import { runClient, sandboxClient } from './client'; - -export enum CellType { UNSPECIFIED=0, SHELL=1, JAVASCRIPT=2, PYTHON=3, AGENT=4 } -export type WorkSession={id:string;title:string;status:string;driver:string;guestImage:string;workspacePath:string;triggerSource:string;createdAt:string;updatedAt:string;cellCount:number;eventCount:number;tags:Array<{name:string;value:string}>}; -export type WorkSessionDetail=WorkSession&{proxyPath:string;notebookUrl:string}; -export type WorkSessionCell={id:string;runId?:string;source:string;output:string;type:CellType;exitCode:number;success:boolean;createdAt:string;agent:string;agentSessionId:string;stopReason:string;running:boolean}; -export type WorkSessionEvent={id:string;type:string;level:string;message:string;createdAt:string}; -export type WorkSessionWatchEvent={type:'session';session:WorkSession}|{type:'event';event:WorkSessionEvent}|{type:'cell';cell:WorkSessionCell}|{type:'chunk';cellId:string;chunk:string;isStderr:boolean}; -export type WorkSessionRunTarget={projectId:string;agentName:string}; - -export async function listWorkSessions(limit=50,offset=0):Promise<{sessions:WorkSession[];hasMore:boolean;totalCount:number}>{let token='';let skipped=0;const sessions:WorkSession[]=[];for(;;){const response=await sandboxClient.listSandboxes({limit:Math.min(500,limit+offset),cursor:token});const pageStartCount=sessions.length;let usablePageCount=0;for(const item of response.sandboxes){if(skipped++=limit)return{sessions,hasMore:Boolean(token)||usablePageCount>limit-pageStartCount,totalCount:offset+sessions.length+(token?1:0)}}} -export async function getWorkSession(id:string,_options:{includeProxy?:boolean}={}):Promise{const response=await sandboxClient.getSandbox({sandboxId:id});if(!response.sandbox)throw new Error('工作会话不存在');return{...sessionFromSandbox(response.sandbox),proxyPath:response.sandbox.proxyPath,notebookUrl:response.sandbox.notebookUrl}} -export async function getWorkSessionProxy(id:string){const value=await getWorkSession(id);return{proxyPath:value.proxyPath,notebookUrl:value.notebookUrl}} -export async function getWorkSessionStatus(id:string):Promise{return getWorkSession(id)} -export async function stopWorkSession(id:string):Promise{const response=await sandboxClient.stopSandbox({sandboxId:id});if(!response.sandbox)throw new Error('停止会话失败');return sessionFromSandbox(response.sandbox)} -export async function resumeWorkSession(id:string):Promise{const response=await sandboxClient.resumeSandbox({sandboxId:id});if(!response.sandbox)throw new Error('恢复会话失败');return sessionFromSandbox(response.sandbox)} - -type WorkSessionHistory = { cells: WorkSessionCell[]; events: WorkSessionEvent[] }; -const historyRequests = new Map>(); -type SandboxRuns = Awaited>['runs']; -const sandboxRunRequests = new Map>(); - -export async function listWorkSessionCells(id:string):Promise{return (await workSessionHistory(id)).cells} -export async function listWorkSessionEvents(id:string):Promise{return (await workSessionHistory(id)).events} - -function workSessionHistory(id:string):Promise{ - const existing=historyRequests.get(id); - if(existing)return existing; - const request=loadWorkSessionHistory(id); - historyRequests.set(id,request); - const clear=()=>setTimeout(()=>historyRequests.delete(id),1000); - void request.then(clear,clear); - return request; -} - -async function loadWorkSessionHistory(id:string):Promise{ - const runs=await runsForSandbox(id); - const legacyResponse=await sandboxClient.listSandboxHistory({sandboxId:id}); - const legacyHistory=historyFromSandboxResponse(legacyResponse); - if(!runs.length){ - return legacyHistory; - } - const firstV2RunAt=runs[0].createdAt; - const cells:WorkSessionCell[]=[]; - const events:WorkSessionEvent[]=[]; - const historyAvailableRunIds=new Set(); - let cursor=''; - do{ - const response=await runClient.listSandboxRunEvents({sandboxId:id,limit:500,cursor}); - for(const runId of response.historyAvailableRunIds)historyAvailableRunIds.add(runId); - for(const item of response.events){ - if(item.kind===RunEventKind.USER_MESSAGE||item.kind===RunEventKind.AGENT_MESSAGE)cells.push({id:item.id,runId:item.runId,source:item.kind===RunEventKind.USER_MESSAGE?item.text:'',output:item.kind===RunEventKind.AGENT_MESSAGE?item.text:'',type:CellType.AGENT,exitCode:item.exitCode,success:item.success,createdAt:timestampString(item.createdAt),agent:item.agent,agentSessionId:'',stopReason:item.stopReason,running:false}); - else if(item.kind===RunEventKind.STATUS)events.push({id:item.id,type:'run.status',level:item.success?'info':'error',message:item.stopReason||item.payloadJson,createdAt:timestampString(item.createdAt)}); - } - cursor=response.nextCursor; - }while(cursor); - for(const run of runs){ - if(!historyAvailableRunIds.has(run.runId)){ - let logs=''; - for await(const chunk of runClient.followRunLogs({runId:run.runId,projectId:run.projectId,follow:false,tailLines:1000}))logs+=chunk.data; - if(logs)cells.push({id:`${run.runId}-legacy-log`,source:'',output:logs,type:CellType.AGENT,exitCode:0,success:true,createdAt:run.createdAt,agent:run.agentName,agentSessionId:'',stopReason:'旧运行:仅日志记录',running:false}); - } - } - cells.push(...legacyHistory.cells.filter((cell)=>isBeforeV2History(cell.createdAt,firstV2RunAt))); - events.push(...legacyHistory.events.filter((event)=>isBeforeV2History(event.createdAt,firstV2RunAt))); - cells.sort((left,right)=>left.createdAt.localeCompare(right.createdAt)); - events.sort((left,right)=>left.createdAt.localeCompare(right.createdAt)); - return {cells,events}; -} -export async function getWorkSessionRunTarget(id:string):Promise{ - const run=await latestRunForSandbox(id); - if(run)return{projectId:run.projectId,agentName:run.agentName}; - const response=await sandboxClient.getSandbox({sandboxId:id}); - const projectId=response.sandbox?.projectId.trim()??''; - const agentName=response.sandbox?.agentName.trim()??''; - return projectId&&agentName?{projectId,agentName}:undefined; -} -export async function sendWorkSessionMessage(id:string,agent:string,message:string):Promise{await sendWorkSessionMessageStream(id,agent,message,()=>{})} -export async function sendWorkSessionMessageStream(id:string,_agent:string,message:string,onEvent:(event:{type:'started'|'chunk'|'completed';runId:string;chunk?:string;isStderr?:boolean;run?:{id:string;agent:string;message:string;output:string;exitCode:number;success:boolean;createdAt:string;agentSessionId:string;stopReason:string;running:boolean}})=>void,signal?:AbortSignal,target?:WorkSessionRunTarget):Promise{const previous=await latestRunForSandbox(id);const resolved=previous?{projectId:previous.projectId,agentName:previous.agentName}:target;if(!resolved)throw new Error('旧工作会话没有可用的 v2 项目和智能体,只能查看历史;请从智能体页面发起新运行');for await(const event of runClient.runAgentStream({projectId:resolved.projectId,agentName:resolved.agentName,prompt:message,source:RunSource.MANUAL,sandboxId:id,cleanupPolicy:RunSandboxCleanupPolicy.KEEP_RUNNING},{signal})){if(event.eventType===RunAgentStreamEventType.STARTED)onEvent({type:'started',runId:event.runId});else if(event.eventType===RunAgentStreamEventType.OUTPUT)onEvent({type:'chunk',runId:event.runId,chunk:event.chunk,isStderr:event.stream===2});else if(event.eventType===RunAgentStreamEventType.COMPLETED){const run=event.run;onEvent({type:'completed',runId:event.runId,run:run?{id:run.runId,agent:run.agentName,message,output:'',exitCode:run.exitCode,success:run.status===3,createdAt:run.createdAt,agentSessionId:'',stopReason:run.error,running:false}:undefined})}}} -export async function watchWorkSession(id:string,onEvent:(event:WorkSessionWatchEvent)=>void,signal?:AbortSignal):Promise{for await(const item of sandboxClient.watchSandbox({sandboxId:id},{signal})){if(item.eventType===SandboxWatchEventType.SANDBOX_UPDATED&&item.sandbox){onEvent({type:'session',session:sessionFromSandbox(item.sandbox)})}else if((item.eventType===SandboxWatchEventType.CELL_STARTED||item.eventType===SandboxWatchEventType.CELL_COMPLETED)&&item.cell){const cell=item.cell;onEvent({type:'cell',cell:{id:cell.id,source:cell.source,output:cell.output||[cell.stdout,cell.stderr].filter(Boolean).join('\n'),type:legacyCellType(cell.type),exitCode:cell.exitCode,success:cell.success,createdAt:timestampString(cell.createdAt),agent:cell.agent,agentSessionId:cell.agentThreadId,stopReason:cell.stopReason,running:cell.running}})}else if(item.eventType===SandboxWatchEventType.EVENT_ADDED&&item.event){onEvent({type:'event',event:{id:item.event.id,type:item.event.type,level:item.event.level,message:item.event.message,createdAt:timestampString(item.event.createdAt)}})}else if(item.eventType===SandboxWatchEventType.CELL_OUTPUT){onEvent({type:'chunk',cellId:item.cellId,chunk:item.chunk,isStderr:item.stream===2})}}} - -async function latestRunForSandbox(id:string){const runs=await runsForSandbox(id);return runs[runs.length-1]} -async function runsForSandbox(id:string){ - let request=sandboxRunRequests.get(id); - if(!request){ - request=loadRunsForSandbox(id); - sandboxRunRequests.set(id,request); - const clear=()=>setTimeout(()=>sandboxRunRequests.delete(id),1000); - void request.then(clear,clear); - } - return request; -} -async function loadRunsForSandbox(id:string):Promise{ - const runs:SandboxRuns=[]; - let offset=0; - for(;;){ - const response=await runClient.listRuns({sandboxId:id,limit:200,offset}); - runs.push(...response.runs); - if(response.runs.length<200)break; - offset+=response.runs.length; - } - return runs.sort((left,right)=>runTimeValue(left.createdAt)-runTimeValue(right.createdAt)||left.runId.localeCompare(right.runId)); -} -function legacyCellType(value:string):CellType{switch(value.trim().toLowerCase()){case'shell':return CellType.SHELL;case'javascript':return CellType.JAVASCRIPT;case'python':return CellType.PYTHON;default:return CellType.AGENT}} -function historyFromSandboxResponse(response:Awaited>):WorkSessionHistory{return{cells:response.cells.map((item)=>({id:item.id,source:item.source,output:item.output||[item.stdout,item.stderr].filter(Boolean).join('\n'),type:legacyCellType(item.type),exitCode:item.exitCode,success:item.success,createdAt:timestampString(item.createdAt),agent:item.agent,agentSessionId:item.agentThreadId,stopReason:item.stopReason||'旧会话历史',running:item.running})),events:response.events.map((item)=>({id:item.id,type:item.type,level:item.level,message:item.message,createdAt:timestampString(item.createdAt)}))}} -function isBeforeV2History(createdAt:string,firstV2RunAt:string):boolean{return!createdAt||!firstV2RunAt||runTimeValue(createdAt),name:string):string{return tags.find((tag)=>tag.name.trim()===name)?.value.trim()??''} -function sessionFromSandbox(item:Sandbox):WorkSession{return{id:item.sandboxId,title:item.title,status:item.status,driver:item.driver,guestImage:item.image,workspacePath:item.workspacePath,triggerSource:item.triggerSource,createdAt:timestampString(item.createdAt),updatedAt:timestampString(item.updatedAt),cellCount:item.cellCount,eventCount:item.eventCount,tags:item.tags.map((v)=>({name:v.name,value:v.value}))}} -function timestampString(value?:{seconds:bigint;nanos:number}){return value?new Date(Number(value.seconds)*1000+value.nanos/1e6).toISOString():''} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..366e949 --- /dev/null +++ b/src/app.css @@ -0,0 +1,58 @@ +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --border-color: #30363d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #6e7681; + --accent-blue: #58a6ff; + --accent-green: #3fb950; + --accent-red: #f85149; + --accent-yellow: #d2991d; + --accent-orange: #db6d28; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: system-ui, -apple-system, sans-serif; + /* Typographic scale — every text size in the app uses one of these tokens. */ + --font-size-xs: 12px; /* micro: badges, table heads, status pills, timestamps, mono meta */ + --font-size-sm: 13px; /* secondary: captions, descriptions, notices */ + --font-size-md: 14px; /* body: nav, inputs, buttons, list rows — root size */ + --font-size-lg: 15px; /* emphasized: list titles, agent names */ + --font-size-xl: 16px; /* section/panel headings, view h2 */ + --font-size-2xl: 18px; /* modal titles, page subheadings */ + --font-size-3xl: 20px; /* page h1 */ + --font-size-hero: 24px; /* hero stat numbers */ + --sidebar-width: 220px; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body, #app { + height: 100%; + overflow: hidden; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: var(--font-size-md); + line-height: 1.5; +} + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: var(--bg-primary); } +::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; } + +button { + cursor: pointer; + font-family: inherit; + font-size: inherit; +} + +input, select, textarea { + font-family: inherit; + font-size: inherit; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 4px 8px; +} diff --git a/src/components/AntIcon.svelte b/src/components/AntIcon.svelte deleted file mode 100644 index 33c45c7..0000000 --- a/src/components/AntIcon.svelte +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/src/components/Resizer.svelte b/src/components/Resizer.svelte new file mode 100644 index 0000000..4d7f5b1 --- /dev/null +++ b/src/components/Resizer.svelte @@ -0,0 +1,85 @@ + + +
+ + +
+ + diff --git a/src/components/RuntimeCommandTerminal.svelte b/src/components/RuntimeCommandTerminal.svelte deleted file mode 100644 index f7f28a6..0000000 --- a/src/components/RuntimeCommandTerminal.svelte +++ /dev/null @@ -1,412 +0,0 @@ - - -
-
-
-

命令终端

-

{targetLabel}

-
-
- {#if disabled} - {disabledReason || '不可用'} - {:else} - {status} - {/if} -
-
- -
- - - - - - -
- - {#if error} -
{error}
- {/if} - -
- - {#if history.length > 0} -
- {#each history as item} - - {/each} -
- {/if} -
- - diff --git a/src/components/ScriptLineActions.test.js b/src/components/ScriptLineActions.test.js new file mode 100644 index 0000000..4abbc6b --- /dev/null +++ b/src/components/ScriptLineActions.test.js @@ -0,0 +1,44 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./scripts/ScriptLineActions.svelte', import.meta.url), 'utf8'); + +test('shows mutually exclusive actions for empty and populated inline scripts', () => { + expect(source).toContain("kind === 'inline'"); + expect(source).toContain('{#if empty}'); + expect(source).toContain('aria-pressed="true"'); + expect(source).toContain('>内联脚本'); + expect(source).toContain('{:else}'); + expect(source).toContain('更新文件'); + expect(source).toContain('提取到文件'); + expect(source).toContain('fileExists'); +}); + +test('only offers referencing when the inline script is empty', () => { + expect(source).toContain('>📁 引用已有文件'); + const emptyBranch = source.slice(source.indexOf('{#if empty}'), source.indexOf('{:else}')); + expect(emptyBranch).toContain('onReference'); + const populatedBranch = source.slice(source.indexOf('{:else}'), source.indexOf('{/if}', source.indexOf('{:else}'))); + expect(populatedBranch).not.toContain('onReference'); +}); + +test('matches the demo glyph styling without a floating card', () => { + expect(source).toContain('font-size: var(--font-size-xs)'); + expect(source).toContain('padding: 2px 8px'); + expect(source).toContain('border: 1px solid #a371f7'); + expect(source).toContain('background: rgba(163, 113, 247, 0.12)'); + expect(source).toContain('button:hover { background: rgba(163, 113, 247, 0.25); }'); + expect(source).not.toContain('box-shadow'); + expect(source).not.toContain('action-label'); +}); + +test('shows file actions for script references', () => { + expect(source).toContain('切换内联'); +}); + +test('shrinks long actions inside a narrow editor viewport', () => { + expect(source).toContain('min-width: 0'); + expect(source).toContain('max-width: 100%'); + expect(source).toContain('overflow: hidden'); + expect(source).toContain('text-overflow: ellipsis'); +}); diff --git a/src/components/SessionOutputPanel.svelte b/src/components/SessionOutputPanel.svelte deleted file mode 100644 index 60a5412..0000000 --- a/src/components/SessionOutputPanel.svelte +++ /dev/null @@ -1,364 +0,0 @@ - - -
-
-

会话输出

-
- - {search.matches.length > 0 ? `${search.currentIndex + 1}/${search.matches.length}` : '0/0'} - - - - -
-
- - {#if visibleCells.length > 0} -
- {#each visibleCells as cell} - {@const source = sessionMessageSource(cell)} - {@const output = sessionMessageOutput(cell)} - {@const sourceMatch = sectionMatch(search.matches[search.currentIndex], cell.id, 'source')} - {@const outputMatch = sectionMatch(search.matches[search.currentIndex], cell.id, 'output')} - {#if source} -
-
-
用户{cell.id}
-
{formatTime(cell.createdAt)}
-
-
{#if sourceMatch}{#each sessionOutputMatchParts(source, sourceMatch) as part}{#if part.matched}{part.text}{:else}{part.text}{/if}{/each}{:else}{source}{/if}
-
- {/if} - {#if output} -
-
-
{cell.agent || '助手'}{cell.id}{sessionCellStatus(cell)}
-
{formatTime(cell.createdAt)}
-
- {#if isAgentSessionCell(cell)} -
-
{#if outputMatch}{#each sessionOutputMatchParts(output, outputMatch) as part}{#if part.matched}{part.text}{:else}{part.text}{/if}{/each}{:else}{output}{/if}
-
- {:else} -
{#if outputMatch}{#each sessionOutputMatchParts(output, outputMatch) as part}{#if part.matched}{part.text}{:else}{part.text}{/if}{/each}{:else}{output}{/if}
- {/if} -
- {/if} - {/each} -
- {:else} -
暂无会话输出。
- {/if} -
- - diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte new file mode 100644 index 0000000..ea8c11c --- /dev/null +++ b/src/components/Sidebar.svelte @@ -0,0 +1,584 @@ + + + + + diff --git a/src/components/Sidebar.test.js b/src/components/Sidebar.test.js new file mode 100644 index 0000000..e0b94b7 --- /dev/null +++ b/src/components/Sidebar.test.js @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync( + new URL('./Sidebar.svelte', import.meta.url), + 'utf8', +); + +test('deletes a project from a separate button without selecting it', () => { + assert.match(source, /class="project-delete"/); + assert.match(source, /]*class="delete-icon"[\s\S]* { + assert.match(source, /project\.summary\.name/); + assert.match(source, /project\.summary\.sourcePath/); + assert.match(source, /window\.confirm/); +}); + +test('clears selection and opens the empty new-project editor after deleting the active project', () => { + assert.match(source, /store\.removeProjectEditor\(projectId\)/); + assert.match(source, /store\.activeProjectId = ''/); + assert.match(source, /store\.editorContent = EMPTY_YAML_TEMPLATE/); + assert.match(source, /store\.goTo\('project'\)/); +}); + +test('does not render the overview menu', () => { + assert.doesNotMatch(source, />\s*总览\s* { + assert.match(source, /class="project-filter"/); + assert.match(source, /placeholder="筛选应用…"/); + assert.match(source, /oninput=\{onFilterInput\}/); +}); + +test('sends the project filter to backend query', () => { + assert.match(source, /query: filterText\.trim\(\)/); + assert.match(source, /loadProjects\(true\)/); +}); + +test('paginates the list through ListProjects offset and limit', () => { + assert.match(source, /const PAGE_SIZE = 10/); + assert.match(source, /offset: reset \? 0 : projectOffset/); + assert.match(source, /limit: PAGE_SIZE/); + assert.match(source, /resp\.hasMore/); + assert.match(source, /resp\.nextOffset/); + assert.match(source, /加载更多/); +}); + +test('ignores stale filter responses and always clears loading state', () => { + assert.match(source, /requestGeneration/); + assert.match(source, /generation !== requestGeneration/); + assert.match(source, /finally/); + assert.match(source, /filterLoading = false/); +}); + +test('shows an empty hint when no project matches the filter', () => { + assert.match(source, /无匹配的智能体应用/); +}); + +test('opens unified system management at daemon images', () => { + assert.match(source, />\s*系统管理\s*资源管理/); + assert.match(source, /store\.goTo\('images'\)/); +}); + +test('uses the approved compact high-density dimensions', () => { + assert.match(source, /\.brand\s*\{[\s\S]*?min-height:\s*46px/); + assert.match(source, /\.section-header\s*\{[^}]*font-size:\s*var\(--font-size-md\)/); + assert.match(source, /\.project-filter\s*\{[\s\S]*?height:\s*30px/); + assert.match(source, /\.filter-box\s*\{[\s\S]*?padding:\s*0 0 6px/); + assert.match(source, /\.project-row\s*\{[\s\S]*?min-height:\s*30px/); + assert.match(source, /\.project-name\s*\{[\s\S]*?font-family:\s*var\(--font-mono\)/); + assert.match(source, /border:\s*1px solid color-mix\(in srgb, var\(--accent-green\) 10%, var\(--border-color\)\)/); + assert.match(source, /\.new-project-btn\s*\{[\s\S]*?text-align:\s*center/); +}); + +test('exposes list structure without decorative ordinal indices', () => { + assert.match(source, /id="project-list-heading"/); + assert.match(source, /aria-labelledby="project-list-heading"/); + assert.doesNotMatch(source, /class="project-index"/); +}); + +test('keeps active, delete, and keyboard focus states visible', () => { + assert.match(source, /\.project-row\.active::before/); + assert.match(source, /\.project-row:focus-within \.project-delete/); + assert.match(source, /:focus-visible/); +}); + +test('renders equally sized load-more and create buttons', () => { + assert.match(source, /class="project-actions"[\s\S]*class="load-more-btn"[\s\S]*class="new-project-btn"/); + assert.match(source, /\.load-more-btn,[\s\S]*?\.new-project-btn\s*\{[\s\S]*?width:\s*100%[\s\S]*?min-height:\s*30px/); + assert.match(source, /\.load-more-btn\s*\{[\s\S]*?border:\s*1px solid var\(--border-color\)/); + assert.match(source, /\.new-project-btn:hover[\s\S]*?border-color:\s*color-mix\(in srgb, var\(--accent-green\) 30%, var\(--border-color\)\)/); + assert.match(source, /\.project-actions\s*\{[\s\S]*?border-top:\s*1px solid var\(--border-color\)/); +}); diff --git a/src/components/StatusIndicator.svelte b/src/components/StatusIndicator.svelte new file mode 100644 index 0000000..fae6717 --- /dev/null +++ b/src/components/StatusIndicator.svelte @@ -0,0 +1,27 @@ + + + + {#if synced} + ✓ 已同步 + {:else if specHash} + {specHash.slice(0, 7)} + {:else} + 未保存 + {/if} + + + diff --git a/src/components/Toast.svelte b/src/components/Toast.svelte new file mode 100644 index 0000000..49a4e3a --- /dev/null +++ b/src/components/Toast.svelte @@ -0,0 +1,61 @@ + + +
+ {#each store.toasts as t (t.id)} +
+ + {#if t.level === 'error'}✕ + {:else if t.level === 'success'}✓ + {:else}ℹ + {/if} + +
{t.message}
+
+ {/each} +
+ + diff --git a/src/components/Toolbar.svelte b/src/components/Toolbar.svelte new file mode 100644 index 0000000..52dfac0 --- /dev/null +++ b/src/components/Toolbar.svelte @@ -0,0 +1,1142 @@ + + +
+
+ +
+
+ + + + + + + +
+
+ +{#if showDiff} + +
{ if (e.key === 'Escape') closePreview(); }} + role="dialog" + tabindex="-1" + > + + +
e.stopPropagation()} + onkeydown={(e: KeyboardEvent) => e.stopPropagation()} + role="document" + > +
+
+ 服务端预览 +

确认本次变更

+

+ {changedChanges.length > 0 + ? `服务端预计修改 ${changedChanges.length} 项,确认后才会应用。` + : previewUnchanged ? '服务端确认当前配置无需变更。' : '服务端未返回资源变更。'} +

+
+ +
+ +
+
+ {changedChanges.length} + 项变更 +
+
+ + 新增 {createdCount} + 更新 {updatedCount} + 删除 {removedCount} +
+
+ +
+ {#if previewIssues.length > 0} +
服务端问题{previewIssues.length}
+
{#each previewIssues as issue}
[{String(issue.severity)}] {issue.path}
{issue.message}
{/each}
+
+ {/if} + {#if changedChanges.length > 0} +
+
+ 已修改的内容 + {changedChanges.length} +
+
+ {#each changedChanges as c} +
+ +
+
+ {displayName(c)} + {actionLabel(c.action)} +
+
+ {resourceLabel(c.resourceType)} + {#if c.message}{c.message}{/if} +
+
+
+ {/each} +
+
+ {:else} +
+ + 所有内容均为最新 +

当前配置与已保存版本一致。

+
+ {/if} + + {#if buildPlans.length > 0} +
+
本次镜像处理{buildPlans.length} 个可构建智能体
+ {#if buildResults.length === 0} + + + {#if buildChoice === 'build'} +
本次构建选项
+ + +
+ {#each buildPlans.filter((plan) => !!plan.error) as plan} +
{plan.agentName}:{plan.error}
+ {/each} + {/if} +

构建成功的镜像会保留在 daemon,即使后续应用配置失败。

+ {:else} +
+ {#each buildResults as result} +
+ {result.status === 'succeeded' ? '✓' : result.status === 'failed' ? '!' : result.status === 'building' ? '●' : '○'} + {result.agentName}{result.imageRef} + {result.status === 'succeeded' ? '构建成功' : result.status === 'failed' ? '构建失败' : result.status === 'building' ? '构建中' : result.status === 'unexecuted' ? '未执行' : '等待中'} +
+ {#if result.status === 'building' || result.status === 'failed'} +
+ {#each result.stream.lines as line}
{line.stage || 'build'}{line.message}
{/each} + {#if result.error}
{result.error}
{/if} +
+ {/if} + {/each} +
+ {/if} + {#if buildError}
{buildError}
{/if} +
+ {/if} + + {#if unchangedChanges.length > 0} +
+ + {#if showUnchanged} +
+ {#each unchangedChanges as c} +
+ {resourceLabel(c.resourceType)} + {displayName(c)} +
+ {/each} +
+ {/if} +
+ {/if} +
+ +
+ 确认启用后,当前配置将覆盖上次启用的版本。 +
+
+
+
+
+{/if} + + diff --git a/src/components/ToolbarImageBuild.test.js b/src/components/ToolbarImageBuild.test.js new file mode 100644 index 0000000..5d5422c --- /dev/null +++ b/src/components/ToolbarImageBuild.test.js @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, test } from 'bun:test'; + +const source = readFileSync(new URL('./Toolbar.svelte', import.meta.url), 'utf8'); + +describe('Toolbar YAML image build confirmation', () => { + test('creates build plans from the frozen prepared YAML and daemon source path', () => { + expect(source).toMatch(/createProjectImageBuildPlans/); + expect(source).toMatch(/changedBuildAgentNames/); + expect(source).toMatch(/GetProjectRequest/); + expect(source).toMatch(/includeSpec:\s*true/); + expect(source).toMatch(/preview\.prepared\.yamlText/); + expect(source).toMatch(/summary\.sourcePath/); + }); + + test('offers an explicit build-or-skip choice and per-agent selection', () => { + expect(source).toMatch(/构建 YAML 中配置的镜像/); + expect(source).toMatch(/仅应用配置,不构建镜像/); + expect(source).toMatch(/selectedBuildAgents/); + expect(source).toMatch(/本次构建选项/); + expect(source).toMatch(/不使用缓存/); + expect(source).toMatch(/拉取最新基础镜像/); + }); + + test('shows the real phase rail and stream progress', () => { + expect(source).toMatch(/构建镜像/); + expect(source).toMatch(/应用配置/); + expect(source).toMatch(/启动运行/); + expect(source).toMatch(/buildResults/); + expect(source).toMatch(/result\.stream\.lines/); + }); + + test('builds before applying and preserves the preview on build failure', () => { + expect(source).toMatch(/async function confirmBuildAndApply/); + expect(source).toMatch(/await runProjectImageBuildPlans/); + expect(source).toMatch(/await confirmApply/); + const orchestration = source.slice(source.indexOf('async function confirmBuildAndApply'), source.indexOf('function buildActionLabel')); + expect(orchestration.indexOf('await runProjectImageBuildPlans')).toBeLessThan(orchestration.lastIndexOf('await confirmApply')); + expect(source).toMatch(/ProjectImageBuildRunError/); + expect(source).toMatch(/pendingApply/); + }); +}); diff --git a/src/components/ToolbarLatestRun.component.test.ts b/src/components/ToolbarLatestRun.component.test.ts new file mode 100644 index 0000000..ab1e39e --- /dev/null +++ b/src/components/ToolbarLatestRun.component.test.ts @@ -0,0 +1,516 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { ProjectChangeAction, RunStatus } from '../gen/agentcompose/v2/agentcompose_pb'; +import { yamlRunBatches } from '../lib/yaml-run-batch.svelte'; +import Toolbar from './Toolbar.svelte'; + +const mocks = vi.hoisted(() => ({ + store: { + activeProjectId: 'p1', editorContent: 'yaml', projects: [{ summary: { projectId: 'p1', name: 'P1', sourcePath: '' } }], + runtimeView: { level: 'agents' }, toasts: [] as unknown[], runtimeRefreshVersion: 0, + navigateTo: vi.fn((level: string) => { mocks.store.runtimeView = { level }; }), + addToast: vi.fn(), triggerRuntimeRefresh: vi.fn(), syncHash: vi.fn(), saveEditorDraft: vi.fn(), removeEditorDraft: vi.fn(), + }, + startRun: vi.fn(), + softPauseProject: vi.fn(), + probeProjectRuntimeActivity: vi.fn(), + stopProjectRuns: vi.fn(), + getProject: vi.fn(), + getScheduler: vi.fn(), + listSchedulers: vi.fn(), + setSchedulerEnabled: vi.fn(), + listSchedulerRuns: vi.fn(), + stopSchedulerRun: vi.fn(), + listRuns: vi.fn(), + stopRun: vi.fn(), + listSandboxes: vi.fn(), + stopSandbox: vi.fn(), + runCalls: [] as Array, + currentSpec: { name: 'P1', agents: [{ name: 'a', systemPrompt: 'A' }, { name: 'b', systemPrompt: 'B' }] } as any, + savedSpec: { name: 'P1', agents: [] } as any, + buildPlans: [] as Array, + changedBuildAgents: new Set(), + runProjectImageBuildPlans: vi.fn(), + checkProjectDependencies: vi.fn(), + previewResponse: { changes: [] as Array, issues: [] as Array, unchanged: true }, + applyResponse: { applied: true, project: { summary: { projectId: 'p1' } }, changes: [] as Array }, +})); + +vi.mock('../lib/stores.svelte', () => ({ store: mocks.store })); +vi.mock('../lib/rpc', () => ({ + projectService: { getProject: mocks.getProject, getScheduler: mocks.getScheduler, listSchedulers: mocks.listSchedulers, setSchedulerEnabled: mocks.setSchedulerEnabled, listSchedulerRuns: mocks.listSchedulerRuns, stopSchedulerRun: mocks.stopSchedulerRun }, imageService: {}, + capabilityService: {}, + runService: { startRun: mocks.startRun, followRunLogs: vi.fn(), listRuns: mocks.listRuns, stopRun: mocks.stopRun }, + sandboxService: { listSandboxes: mocks.listSandboxes, stopSandbox: mocks.stopSandbox }, +})); +vi.mock('../lib/project-dependency-preflight', () => ({ checkProjectDependencies: mocks.checkProjectDependencies })); +vi.mock('../lib/yaml', () => ({ yamlToSpec: () => ({ spec: mocks.currentSpec }) })); +vi.mock('../lib/scripts/api', () => ({ scriptApi: { readFile: vi.fn(), ensureProject: vi.fn(), writeManifest: vi.fn() }, scriptErrorMessage: (error: unknown) => String(error) })); +vi.mock('../lib/scripts/workspace.svelte', () => ({ scriptWorkspace: { tree: [], files: new Map(), panelOpen: false } })); +vi.mock('../lib/scripts/tree', () => ({ countScriptFiles: () => 0 })); +vi.mock('../lib/scripts/request-pipeline', () => ({ prepareScriptRequest: vi.fn() })); +vi.mock('../lib/scripts/project-lifecycle', () => ({ canonicalProjectId: (id: string) => id })); +vi.mock('../lib/project-image-build', () => ({ + changedBuildAgentNames: () => mocks.changedBuildAgents, + createProjectImageBuildPlans: () => mocks.buildPlans, + ProjectImageBuildRunError: class extends Error {}, + runProjectImageBuildPlans: mocks.runProjectImageBuildPlans, +})); +vi.mock('../lib/toolbar-actions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + softPauseProject: mocks.softPauseProject, + probeProjectRuntimeActivity: mocks.probeProjectRuntimeActivity, + stopProjectRuns: mocks.stopProjectRuns, + prepareProjectPreview: vi.fn(async (options: any) => { + const prepared = { yamlText: 'yaml', references: [] }; + await options.preflight?.(prepared); + return { + currentProjectId: 'p1', editorContent: 'yaml', prepared, generation: 0, + response: mocks.previewResponse, + apply: async () => ({ response: mocks.applyResponse, agentNames: ['a', 'b'], agents: [{ name: 'a', prompt: 'A', hasScheduler: false }, { name: 'b', prompt: 'B', hasScheduler: false }], supersededProjectId: '' }), + }; }), + runYamlBatch: vi.fn((options: any) => { + mocks.runCalls.push(options); + options.onStarting('a'); + return new Promise(() => {}); + }), + }; +}); + +async function applyAndRun() { + await fireEvent.click(hiddenRunButton()); + await fireEvent.click(await screen.findByRole('button', { name: '启用并运行' })); + await waitFor(() => expect(mocks.runCalls.length).toBeGreaterThan(0)); +} + +function hiddenRunButton(): HTMLButtonElement { + const button = screen.getByText('运行', { selector: 'button' }); + return button as HTMLButtonElement; +} + +function hiddenStopAllButton(): HTMLButtonElement { + const button = screen.getByText('停止全部', { selector: 'button' }); + return button as HTMLButtonElement; +} + +function hiddenPauseButton(label = '暂停'): HTMLButtonElement { + return screen.getByText(label, { selector: 'button' }) as HTMLButtonElement; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.store.saveEditorDraft.mockReturnValue({ ok: true, draft: null }); + mocks.runCalls.length = 0; + mocks.store.activeProjectId = 'p1'; + mocks.store.runtimeView = { level: 'agents' }; + mocks.store.projects = [{ summary: { projectId: 'p1', name: 'P1', sourcePath: '/srv/p1/agent-compose.yml' } }]; + mocks.currentSpec = { name: 'P1', agents: [{ name: 'a', systemPrompt: 'A' }, { name: 'b', systemPrompt: 'B' }] }; + mocks.savedSpec = { name: 'P1', agents: [] }; + mocks.buildPlans = []; + mocks.changedBuildAgents = new Set(); + mocks.runProjectImageBuildPlans.mockResolvedValue([]); + mocks.checkProjectDependencies.mockResolvedValue({ warnings: [] }); + mocks.getProject.mockResolvedValue({ project: { spec: mocks.savedSpec } }); + mocks.previewResponse = { changes: [], issues: [], unchanged: true }; + mocks.applyResponse = { applied: true, project: { summary: { projectId: 'p1' } }, changes: [] }; + mocks.softPauseProject.mockResolvedValue({ + schedulers: { attempted: 1, succeeded: 1, failed: 0, errors: [] }, + schedulerRuns: { attempted: 1, succeeded: 1, failed: 0, errors: [] }, + runs: { attempted: 1, succeeded: 1, failed: 0, errors: [] }, + sandboxes: { attempted: 1, succeeded: 1, failed: 0, errors: [] }, + failed: 0, + }); + mocks.probeProjectRuntimeActivity.mockResolvedValue({ scheduler: true, run: false, sandbox: false, active: true }); + mocks.stopProjectRuns.mockResolvedValue({ attempted: 1, stopped: 1, failed: 0 }); + delete (window as any).__refreshProjects; + yamlRunBatches.clearProject('p1'); + yamlRunBatches.clearProject('p2'); +}); + +test('does not render an operation rail in the confirmation dialog', async () => { + mocks.buildPlans = []; + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + + expect(screen.queryByLabelText('执行顺序')).toBeNull(); +}); + +test('saves a browser draft without starting the enable preview', async () => { + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); + + expect(mocks.store.saveEditorDraft).toHaveBeenCalledTimes(1); + expect(mocks.store.addToast).toHaveBeenCalledWith('草稿已保存到此浏览器', 'success'); + expect(screen.queryByText('确认本次变更')).not.toBeInTheDocument(); +}); + +test('rejects saving a browser draft with a duplicate draft name', async () => { + mocks.store.saveEditorDraft.mockReturnValueOnce({ ok: false, reason: 'duplicate-name', name: '重复草稿' }); + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); + + expect(mocks.store.addToast).toHaveBeenCalledWith('草稿名称 "重复草稿" 已存在', 'error'); + expect(mocks.store.addToast).not.toHaveBeenCalledWith('草稿已保存到此浏览器', 'success'); +}); + +test('defaults to skipping image builds when no build configuration changed', async () => { + mocks.buildPlans = [{ agentName: 'reviewer', imageRef: 'reviewer:dev', contextDisplay: '.', dockerfile: 'Dockerfile', request: {}, error: '' }]; + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + + expect(await screen.findByRole('radio', { name: /仅应用配置,不构建镜像/ })).toBeChecked(); + expect(screen.getByText('确认启用后,当前配置将覆盖上次启用的版本。')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: /reviewer/ })).toBeNull(); +}); + +test('build mode hides image details, exposes build options, and builds every valid YAML image', async () => { + mocks.buildPlans = [ + { agentName: 'reviewer', imageRef: 'reviewer:dev', contextDisplay: '.', dockerfile: 'Dockerfile', request: {}, error: '' }, + { agentName: 'writer', imageRef: 'writer:dev', contextDisplay: '.', dockerfile: 'Dockerfile', request: {}, error: '' }, + ]; + mocks.changedBuildAgents = new Set(['writer']); + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + + expect(await screen.findByRole('radio', { name: /构建 YAML 中配置的镜像/ })).toBeChecked(); + expect(screen.queryByRole('checkbox', { name: /reviewer/ })).toBeNull(); + expect(screen.queryByRole('checkbox', { name: /writer/ })).toBeNull(); + expect(screen.getByRole('checkbox', { name: '不使用缓存' })).toBeVisible(); + expect(screen.getByRole('checkbox', { name: '拉取最新基础镜像' })).toBeVisible(); + expect(document.querySelector('.build-options')?.tagName).toBe('DIV'); + + await fireEvent.click(screen.getByRole('button', { name: '构建并启用' })); + await waitFor(() => expect(mocks.runProjectImageBuildPlans).toHaveBeenCalled()); + expect(mocks.runProjectImageBuildPlans.mock.calls[0][0].selectedAgentNames).toEqual(new Set(['reviewer', 'writer'])); +}); + +test('keeps changed content in a dedicated scrolling list', async () => { + mocks.previewResponse = { + changes: Array.from({ length: 14 }, (_, index) => ({ + action: ProjectChangeAction.CREATED, + resourceType: 'agent', + name: `agent-${index + 1}`, + })), + issues: [], + unchanged: false, + }; + const view = render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + + await waitFor(() => expect(view.container.querySelector('.change-list.changed-scroll')).toBeInTheDocument()); +}); + +test('counts only actual changes in the toolbar while retaining unchanged preview details', async () => { + mocks.previewResponse = { + changes: Array.from({ length: 13 }, (_, index) => ({ + action: ProjectChangeAction.UNCHANGED, + resourceType: 'agent', + name: `agent-${index + 1}`, + })), + issues: [], + unchanged: true, + }; + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + + expect(await screen.findByRole('button', { name: '变更 (0)' })).toBeEnabled(); + expect(screen.getByText('未变更内容')).toBeInTheDocument(); + expect(screen.getByText('13 项')).toBeInTheDocument(); +}); + +test('clears completed Apply records from the pending change button after save', async () => { + mocks.previewResponse = { changes: [], issues: [], unchanged: true }; + mocks.applyResponse = { + applied: true, + project: { summary: { projectId: 'p1' } }, + changes: Array.from({ length: 3 }, (_, index) => ({ + action: ProjectChangeAction.CREATED, + resourceType: 'agent', + name: `applied-${index + 1}`, + })), + }; + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + await fireEvent.click(await screen.findByRole('button', { name: '确认启用' })); + + await waitFor(() => expect(screen.getByRole('button', { name: '变更 (0)' })).toBeDisabled()); +}); + +test('shows 启用成功 after Apply succeeds', async () => { + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + await fireEvent.click(await screen.findByRole('button', { name: '确认启用' })); + + await waitFor(() => expect(mocks.store.addToast).toHaveBeenCalledWith('启用成功', 'success')); +}); + +test('removes a new-project browser draft only after Apply succeeds', async () => { + const toolbarActions = await import('../lib/toolbar-actions'); + vi.mocked(toolbarActions.prepareProjectPreview).mockResolvedValueOnce({ + currentProjectId: '', editorContent: 'yaml', prepared: { yamlText: 'yaml', references: [] }, + response: mocks.previewResponse, + apply: async () => ({ ...mocks.applyResponse, response: mocks.applyResponse, agentNames: [], agents: [], supersededProjectId: '' }), + } as any); + mocks.applyResponse = { applied: true, project: { summary: { projectId: 'new-project' } }, changes: [] }; + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + await fireEvent.click(await screen.findByRole('button', { name: '确认启用' })); + + await waitFor(() => expect(mocks.store.removeEditorDraft).toHaveBeenCalledTimes(1)); +}); + +test('creates and opens the batch before starting the first Agent without locking the toolbar on the stream', async () => { + render(Toolbar); + await applyAndRun(); + expect(mocks.store.runtimeView.level).toBe('latest-run'); + expect(yamlRunBatches.current('p1')?.agents.map((agent) => agent.agentName)).toEqual(['a', 'b']); + expect(mocks.runCalls[0].client.startRun).toBe(mocks.startRun); + expect(hiddenRunButton()).not.toBeDisabled(); +}); + +test('refreshes runtime and projects as soon as the first run starts while follow remains deferred', async () => { + const refreshProjects = vi.fn().mockResolvedValue(undefined); + (window as any).__refreshProjects = refreshProjects; + render(Toolbar); + await applyAndRun(); + refreshProjects.mockClear(); + mocks.store.triggerRuntimeRefresh.mockClear(); + + mocks.runCalls[0].onStarted('a', { runId: 'run-a', status: RunStatus.RUNNING }); + + await waitFor(() => expect(refreshProjects).toHaveBeenCalledOnce()); + expect(mocks.store.triggerRuntimeRefresh).toHaveBeenCalledOnce(); + expect(yamlRunBatches.current('p1')?.agents).toMatchObject([ + { agentName: 'a', runId: 'run-a', status: 'running' }, + { agentName: 'b', runId: '', status: 'waiting' }, + ]); +}); + +test('does not duplicate the latest-run entry in the toolbar', async () => { + yamlRunBatches.create('p1', [{ name: 'a', prompt: '' }, { name: 'b', prompt: '' }], 'active'); + render(Toolbar); + expect(screen.queryByRole('button', { name: /运行中监控|最近运行结果/ })).toBeNull(); + expect(mocks.runCalls).toHaveLength(0); + expect(mocks.startRun).not.toHaveBeenCalled(); +}); + +test('a second batch aborts the first and stale callbacks cannot mutate the replacement', async () => { + const view = render(Toolbar); + await applyAndRun(); + const first = mocks.runCalls[0]; + const firstBatchId = yamlRunBatches.current('p1')?.batchId; + view.unmount(); + render(Toolbar); + await applyAndRun(); + const secondBatch = yamlRunBatches.current('p1'); + expect(secondBatch?.batchId).not.toBe(firstBatchId); + expect(first.signal.aborted).toBe(true); + first.onStarted('a', { runId: 'stale', status: RunStatus.RUNNING }); + first.onChunk('a', { runStatus: RunStatus.SUCCEEDED, isFinal: true }); + expect(yamlRunBatches.current('p1')).toEqual(secondBatch); +}); + +test('does not expose another project batch', async () => { + yamlRunBatches.create('p2', [{ name: 'other', prompt: '' }], 'other'); + render(Toolbar); + expect(screen.queryByRole('button', { name: /运行中监控|最近运行结果/ })).toBeNull(); +}); + +test('does not restore a persisted batch into a duplicate toolbar entry', async () => { + const projectId = 'persisted-only'; + mocks.store.activeProjectId = projectId; + localStorage.setItem(`agent-compose:yaml-run:${projectId}`, JSON.stringify({ + version: 1, + batchId: 'persisted', + projectId, + startedAt: '2026-07-15T00:00:00Z', + completedAt: 'done', + agents: [{ agentName: 'a', prompt: '', runId: 'run-a', status: 'succeeded', startError: '' }], + })); + + expect(() => render(Toolbar)).not.toThrow(); + expect(screen.queryByRole('button', { name: '最近运行结果' })).toBeNull(); +}); + +test('shows Pause in the YAML toolbar', () => { + render(Toolbar); + + expect(screen.getAllByRole('button').slice(0, 5).map((button) => button.textContent?.trim())).toEqual([ + '校验', '变更 (0)', '保存草稿', '启用', '暂停', + ]); + expect(hiddenPauseButton()).toBeVisible(); +}); + +test('keeps Run in the DOM but hides it from the YAML toolbar', () => { + render(Toolbar); + + expect(hiddenRunButton()).not.toBeVisible(); +}); + +test('keeps Stop All in the DOM but hides it from the YAML toolbar', () => { + render(Toolbar); + + expect(hiddenStopAllButton()).not.toBeVisible(); +}); + +test('disables Pause when live Scheduler, Run, and Sandbox state is inactive', async () => { + mocks.probeProjectRuntimeActivity.mockResolvedValueOnce({ scheduler: false, run: false, sandbox: false, active: false }); + render(Toolbar); + + await waitFor(() => expect(mocks.probeProjectRuntimeActivity).toHaveBeenCalled()); + expect(hiddenPauseButton()).toBeDisabled(); +}); + +test('shows the Enable busy label while preparing the project preview', async () => { + let finishPreview!: (value: any) => void; + const toolbarActions = await import('../lib/toolbar-actions'); + vi.mocked(toolbarActions.prepareProjectPreview).mockImplementationOnce( + () => new Promise((resolve) => { finishPreview = resolve; }), + ); + render(Toolbar); + + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + + expect(screen.getByRole('button', { name: '启用中…' })).toBeDisabled(); + finishPreview(undefined); + await waitFor(() => expect(screen.getByRole('button', { name: '启用' })).toBeEnabled()); +}); + +test('allows closing the confirmation dialog by aborting an in-flight Apply request', async () => { + const toolbarActions = await import('../lib/toolbar-actions'); + vi.mocked(toolbarActions.prepareProjectPreview).mockResolvedValueOnce({ + currentProjectId: 'p1', editorContent: 'yaml', prepared: { yamlText: 'yaml', references: [] }, + response: mocks.previewResponse, generation: 0, + apply: (signal?: AbortSignal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + } as any); + render(Toolbar); + await fireEvent.click(screen.getByRole('button', { name: '启用' })); + await fireEvent.click(await screen.findByRole('button', { name: '确认启用' })); + + const applying = screen.getByRole('dialog').querySelector('.diff-footer .btn-primary') as HTMLButtonElement; + expect(applying).toHaveAccessibleName('启用中…'); + expect(applying).toBeDisabled(); + expect(applying.querySelector('.loading-spinner')).toBeInTheDocument(); + const close = screen.getByRole('button', { name: '关闭变更记录' }); + expect(close).toBeEnabled(); + await fireEvent.click(close); + + await waitFor(() => expect(screen.queryByText('确认本次变更')).not.toBeInTheDocument()); + expect(mocks.store.addToast).not.toHaveBeenCalledWith(expect.stringContaining('保存失败'), 'error'); +}); + +test('pauses the captured active project through Scheduler, Run, and Sandbox clients', async () => { + render(Toolbar); + + expect(hiddenPauseButton()).toBeVisible(); + await waitFor(() => expect(hiddenPauseButton()).toBeEnabled()); + await fireEvent.click(hiddenPauseButton()); + + await waitFor(() => expect(mocks.softPauseProject).toHaveBeenCalledOnce()); + expect(mocks.softPauseProject).toHaveBeenCalledWith({ + projectId: 'p1', + client: { + listSchedulers: mocks.listSchedulers, + getScheduler: mocks.getScheduler, + setSchedulerEnabled: mocks.setSchedulerEnabled, + listSchedulerRuns: mocks.listSchedulerRuns, + stopSchedulerRun: mocks.stopSchedulerRun, + listRuns: mocks.listRuns, + stopRun: mocks.stopRun, + listSandboxes: mocks.listSandboxes, + stopSandbox: mocks.stopSandbox, + }, + }); +}); + +test('disables all mutating controls while Pause is active', async () => { + let finishPause!: (value: any) => void; + mocks.softPauseProject.mockImplementationOnce(() => new Promise((resolve) => { finishPause = resolve; })); + render(Toolbar); + + await waitFor(() => expect(hiddenPauseButton()).toBeEnabled()); + await fireEvent.click(hiddenPauseButton()); + + expect(screen.getByRole('button', { name: '启用' })).toBeDisabled(); + expect(hiddenRunButton()).toBeDisabled(); + expect(hiddenPauseButton('暂停中...')).toBeDisabled(); + expect(hiddenStopAllButton()).toBeDisabled(); + finishPause({ + schedulers: { attempted: 0, succeeded: 0, failed: 0, errors: [] }, + runs: { attempted: 0, succeeded: 0, failed: 0, errors: [] }, + sandboxes: { attempted: 0, succeeded: 0, failed: 0, errors: [] }, + failed: 0, + }); + await waitFor(() => expect(hiddenPauseButton()).toBeEnabled()); +}); + +test('reports successful Pause and refreshes runtime and projects', async () => { + const refreshProjects = vi.fn().mockResolvedValue(undefined); + (window as any).__refreshProjects = refreshProjects; + render(Toolbar); + + await waitFor(() => expect(hiddenPauseButton()).toBeEnabled()); + await fireEvent.click(hiddenPauseButton()); + + await waitFor(() => expect(mocks.store.addToast).toHaveBeenCalledWith('项目已暂停', 'success')); + expect(mocks.store.triggerRuntimeRefresh).toHaveBeenCalledOnce(); + expect(refreshProjects).toHaveBeenCalledOnce(); +}); + +test('cannot pause repeatedly after refreshed live state is inactive', async () => { + mocks.probeProjectRuntimeActivity + .mockResolvedValueOnce({ scheduler: true, run: false, sandbox: false, active: true }) + .mockResolvedValue({ scheduler: false, run: false, sandbox: false, active: false }); + render(Toolbar); + + await waitFor(() => expect(hiddenPauseButton()).toBeEnabled()); + await fireEvent.click(hiddenPauseButton()); + await waitFor(() => expect(hiddenPauseButton()).toBeDisabled()); + await fireEvent.click(hiddenPauseButton()); + + expect(mocks.softPauseProject).toHaveBeenCalledTimes(1); +}); + +test('reports per-stage counts when Pause is only partially successful', async () => { + mocks.softPauseProject.mockResolvedValueOnce({ + schedulers: { attempted: 3, succeeded: 2, failed: 1, errors: ['scheduler failed'] }, + schedulerRuns: { attempted: 2, succeeded: 1, failed: 1, errors: ['scheduler run failed'] }, + runs: { attempted: 4, succeeded: 3, failed: 1, errors: [] }, + sandboxes: { attempted: 2, succeeded: 0, failed: 2, errors: ['sandbox failed'] }, + failed: 4, + }); + render(Toolbar); + + await waitFor(() => expect(hiddenPauseButton()).toBeEnabled()); + await fireEvent.click(hiddenPauseButton()); + + await waitFor(() => expect(mocks.store.addToast).toHaveBeenCalledWith( + expect.stringMatching(/Scheduler 2 成功\/1 失败.*Scheduler Run 1 成功\/1 失败.*Agent Run 3 成功\/1 失败.*Sandbox 0 成功\/2 失败/), + 'error', + )); +}); + +test('keeps Stop All scoped to Run orchestration only', async () => { + render(Toolbar); + + await fireEvent.click(hiddenStopAllButton()); + + await waitFor(() => expect(mocks.stopProjectRuns).toHaveBeenCalledWith({ projectId: 'p1', client: expect.anything() })); + expect(mocks.softPauseProject).not.toHaveBeenCalled(); + expect(mocks.listSchedulers).not.toHaveBeenCalled(); + expect(mocks.listSandboxes).not.toHaveBeenCalled(); +}); diff --git a/src/components/ToolbarScripts.test.js b/src/components/ToolbarScripts.test.js new file mode 100644 index 0000000..4dd0d41 --- /dev/null +++ b/src/components/ToolbarScripts.test.js @@ -0,0 +1,11 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./Toolbar.svelte', import.meta.url), 'utf8'); + +test('toolbar does not expose the script-panel shortcut', () => { + expect(source).not.toContain('countScriptFiles(scriptWorkspace.tree)'); + expect(source).not.toContain('scriptWorkspace.panelOpen = !scriptWorkspace.panelOpen'); + expect(source).not.toContain('btn-script'); + expect(source).not.toContain('script-dirty-dot'); +}); diff --git a/src/components/V2Unavailable.svelte b/src/components/V2Unavailable.svelte new file mode 100644 index 0000000..3f0bce1 --- /dev/null +++ b/src/components/V2Unavailable.svelte @@ -0,0 +1,22 @@ + + +
+ {#if showContext} +

当前 v2 API 未提供此能力

+ {/if} +

{title}

+

{reason}

+
+ + diff --git a/src/components/V2Unavailable.test.ts b/src/components/V2Unavailable.test.ts new file mode 100644 index 0000000..a76cc3f --- /dev/null +++ b/src/components/V2Unavailable.test.ts @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/svelte'; +import '@testing-library/jest-dom/vitest'; +import { expect, test } from 'vitest'; +import V2Unavailable from './V2Unavailable.svelte'; + +test('explains a missing v2 capability without presenting an action', () => { + render(V2Unavailable, { props: { title: '调度器操作', reason: 'v2 未提供 Scheduler 服务' } }); + expect(screen.getByText('当前 v2 API 未提供此能力')).toBeInTheDocument(); + expect(screen.getByText('v2 未提供 Scheduler 服务')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); + +test('can hide the v2 implementation context for a user-facing placeholder', () => { + render(V2Unavailable, { + props: { title: '总览', reason: '敬请期待', showContext: false }, + }); + expect(screen.getByRole('heading', { name: '总览' })).toBeInTheDocument(); + expect(screen.getByText('敬请期待')).toBeInTheDocument(); + expect(screen.queryByText('当前 v2 API 未提供此能力')).not.toBeInTheDocument(); +}); diff --git a/src/components/YamlEditor.svelte b/src/components/YamlEditor.svelte new file mode 100644 index 0000000..0f39b0e --- /dev/null +++ b/src/components/YamlEditor.svelte @@ -0,0 +1,517 @@ + + +
+ +
store.editorCollapsed = false} onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && (store.editorCollapsed = false)} title="点击展开编辑器" role="button" tabindex="0"> + agent-compose.yml + +
+ +
+ agent-compose.yml + +
+
+
+ + {#if modalRequest} + !modalBusy && (modalRequest = null)} + /> + {/if} +
+ +
+ + diff --git a/src/components/YamlEditor.test.js b/src/components/YamlEditor.test.js new file mode 100644 index 0000000..11b627f --- /dev/null +++ b/src/components/YamlEditor.test.js @@ -0,0 +1,87 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./YamlEditor.svelte', import.meta.url), 'utf8'); + +test('renders script references as clickable Monaco links', () => { + expect(source).toContain('listScriptRanges(store.editorContent)'); + expect(source).toContain("inlineClassName: 'script-ref-link'"); + expect(source).toContain('deltaDecorations'); + expect(source).toContain('打开脚本文件'); +}); + +test('renders state-aware actions for inline scripts', () => { + expect(source).toContain('ScriptLineActions'); + expect(source).toContain('empty:'); + expect(source).toContain('onMode:'); + expect(source).toContain('initializeInlineScript'); + expect(source).toContain('onExtract:'); + expect(source).toContain('onReference:'); + expect(source).toContain('onInline:'); +}); + +test('only opens the reference picker for an empty inline script', () => { + expect(source).toContain('function isEmptyInlineScript'); + expect(source).toContain('if (!range || range.kind !== \'inline\' || !isEmptyInlineScript(range))'); + expect(source).toContain("'只有空的 script 才能引用已有文件'"); + expect(source).toContain("createContextKey('scriptInlineEmpty'"); + expect(source).toContain("precondition: 'scriptInlineEmpty'"); + expect(source).toContain("precondition: 'scriptInlinePopulated'"); +}); + +test('creates extracted files through the workspace flow', () => { + expect(source).toContain('await scriptWorkspace.writeFileForce(path, extracted.content)'); + expect(source).not.toContain('scriptApi.writeFile({ path, content: extracted.content'); +}); + +test('pins line actions to the editor viewport right edge', () => { + expect(source).toContain('const ACTION_RIGHT_GAP = 12'); + expect(source).toContain('target.getLayoutInfo()'); + expect(source).toContain('node.offsetWidth'); + expect(source).toContain('Math.max(0, availableWidth - widgetWidth)'); + expect(source).toContain('onDidScrollChange'); + expect(source).toContain('onDidLayoutChange'); + expect(source).toContain('layoutContentWidget'); +}); + +test('constrains line actions to the visible editor content width', () => { + expect(source).toContain('column: 1'); + expect(source).not.toContain('column: model.getLineMaxColumn(range.startLine)'); + expect(source).toContain('const availableWidth = Math.max(0, viewportRight - layout.contentLeft)'); + expect(source).toContain('node.style.maxWidth = `${availableWidth}px`'); + expect(source).toContain('Math.min(node.offsetWidth, availableWidth)'); +}); + +test('uses the reference modal instead of browser prompts', () => { + expect(source).toContain('ScriptReferenceModal'); + expect(source).not.toContain('window.prompt'); + expect(source).toContain('defaultScriptPath'); +}); + +test('does not subscribe the Monaco initialization effect to editor content', () => { + expect(source).toMatch(/editor = e;\s*untrack\(\(\) => \{\s*updateScriptActionContext\(\);\s*refreshScriptPresentation\(e\);\s*\}\);/); +}); + +test('restores only viewport primitives after external content replacement', () => { + expect(source).not.toContain('saveViewState()'); + expect(source).not.toContain('restoreViewState('); + expect(source).toContain('editor.getScrollTop()'); + expect(source).toContain('editor.getScrollLeft()'); + expect(source).toContain('editor.getSelections()'); + expect(source).toContain('editor.setSelections(selections)'); + expect(source).toContain('editor.setScrollPosition({ scrollTop, scrollLeft })'); +}); + +test('registers a concrete Monaco editor worker', () => { + expect(source).toContain("import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'"); + expect(source).toContain('self.MonacoEnvironment = {'); + expect(source).toContain('getWorker: () => new EditorWorker()'); +}); + +test('does not trigger Monaco rendering from a content widget afterRender callback', () => { + const afterRender = source.slice(source.indexOf('afterRender:'), source.indexOf('target.addContentWidget(widget)')); + expect(afterRender).not.toContain('getScrolledVisiblePosition'); + expect(afterRender).not.toContain('getOffsetForColumn'); + expect(afterRender).not.toContain('node.offsetLeft'); + expect(afterRender).toContain('node.style.transform = `translateX(${targetLeft}px)`'); +}); diff --git a/src/components/scripts/ScriptCreateModal.svelte b/src/components/scripts/ScriptCreateModal.svelte new file mode 100644 index 0000000..69de870 --- /dev/null +++ b/src/components/scripts/ScriptCreateModal.svelte @@ -0,0 +1,166 @@ + + + + + + diff --git a/src/components/scripts/ScriptCreateModal.test.js b/src/components/scripts/ScriptCreateModal.test.js new file mode 100644 index 0000000..a1d3898 --- /dev/null +++ b/src/components/scripts/ScriptCreateModal.test.js @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./ScriptCreateModal.svelte', import.meta.url), 'utf8'); + +test('keeps the creation dialog above Monaco editor overlays', () => { + assert.match(source, /\.modal-backdrop\s*\{[^}]*z-index:\s*(?:[1-9]\d{2,})/s); +}); diff --git a/src/components/scripts/ScriptEditor.svelte b/src/components/scripts/ScriptEditor.svelte new file mode 100644 index 0000000..ec765b7 --- /dev/null +++ b/src/components/scripts/ScriptEditor.svelte @@ -0,0 +1,128 @@ + + +
+
+ {#if workspace.activeFile} + JS + {workspace.activeFile.path} + {#if workspace.activeFile.dirty} + + {/if} + {:else} + 选择一个脚本文件进行编辑 + {/if} + +
+
+
+ + diff --git a/src/components/scripts/ScriptEditor.test.js b/src/components/scripts/ScriptEditor.test.js new file mode 100644 index 0000000..7f076d9 --- /dev/null +++ b/src/components/scripts/ScriptEditor.test.js @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./ScriptEditor.svelte', import.meta.url), 'utf8'); + +test('places the new-script action at the top right of the editor', () => { + assert.match(source, /onCreateFile:\s*\(\) => void/); + assert.match(source, /class="new-script"[^>]*onclick=\{onCreateFile\}[^>]*>\+ 新建脚本<\/button>/s); + assert.match(source, /\.new-script\s*\{[^}]*margin-left:\s*auto/s); +}); diff --git a/src/components/scripts/ScriptLineActions.svelte b/src/components/scripts/ScriptLineActions.svelte new file mode 100644 index 0000000..141293f --- /dev/null +++ b/src/components/scripts/ScriptLineActions.svelte @@ -0,0 +1,54 @@ + + +
+ {#if kind === 'inline'} + {#if empty} + + + {:else} + + {/if} + {:else} + + {/if} +
+ + diff --git a/src/components/scripts/ScriptPanel.component.test.ts b/src/components/scripts/ScriptPanel.component.test.ts new file mode 100644 index 0000000..941bc2c --- /dev/null +++ b/src/components/scripts/ScriptPanel.component.test.ts @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { expect, test, vi } from 'vitest'; +import ScriptPanel from './ScriptPanel.svelte'; +import { createScriptWorkspace } from '../../lib/scripts/workspace.svelte'; + +vi.mock('./ScriptEditor.svelte', async () => ({ + default: (await import('../../test/fixtures/ScriptEditorStub.svelte')).default, +})); + +test('opens the new-script dialog when no script is selected', async () => { + const workspace = createScriptWorkspace({ + listTree: async () => ({ kind: 'directory', name: '', path: '', children: [] }), + readFile: vi.fn(), writeFile: vi.fn(), createFolder: vi.fn(), deleteFile: vi.fn(), deleteFolder: vi.fn(), + }); + workspace.panelOpen = true; + render(ScriptPanel, { workspace }); + + expect(workspace.activeFile).toBeNull(); + await fireEvent.click(screen.getByRole('button', { name: '新建脚本' })); + + expect(screen.getByRole('dialog')).toHaveTextContent('新建脚本文件'); +}); diff --git a/src/components/scripts/ScriptPanel.svelte b/src/components/scripts/ScriptPanel.svelte new file mode 100644 index 0000000..5872d2c --- /dev/null +++ b/src/components/scripts/ScriptPanel.svelte @@ -0,0 +1,252 @@ + + +
+ {#if workspace.panelOpen} + + {/if} + + + {#if workspace.panelOpen} +
+
+ +
+ + (createMode = 'file')} /> +
+ + {#if createMode} + (createMode = null)} + /> + {/if} + {/if} +
+ + diff --git a/src/components/scripts/ScriptPanel.test.js b/src/components/scripts/ScriptPanel.test.js new file mode 100644 index 0000000..8d77e41 --- /dev/null +++ b/src/components/scripts/ScriptPanel.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./ScriptPanel.svelte', import.meta.url), 'utf8'); + +test('composes the tree and the editor from the shared workspace', () => { + assert.match(source, /import ScriptTree from '\.\/ScriptTree\.svelte'/); + assert.match(source, /import ScriptEditor from '\.\/ScriptEditor\.svelte'/); + assert.match(source, /import ScriptCreateModal from '\.\/ScriptCreateModal\.svelte'/); + assert.match(source, / { + assert.match(source, /if \(workspace\.panelOpen\)/); + assert.match(source, /workspace\.refreshTree\(\)/); +}); + +test('refreshes an already-open tree when the active project changes', () => { + assert.match(source, /workspace\.contextRevision;/); +}); + +test('reports API errors via store.addToast', () => { + assert.match(source, /store\.addToast\(/); + assert.match(source, /scriptErrorMessage/); +}); + +test('wires editor script creation to the existing file modal', () => { + assert.match(source, / \(createMode = 'file'\)\} \/>/); + assert.match(source, / { + assert.match(source, /workspace\.deleteFile\(path\)/); + assert.match(source, /workspace\.deleteFolder\(path\)/); +}); + +test('shows a service unavailable indicator when the script service is down', () => { + assert.match(source, /workspace\.serviceAvailable/); + assert.match(source, /header-status/); +}); + +test('supports dragging the top edge to resize the panel vertically', () => { + assert.match(source, /let panelHeight = \$state\(240\)/); + assert.match(source, /function startVerticalResize/); + assert.match(source, /class="panel-resizer"/); + assert.match(source, /aria-label="调整脚本面板高度"/); + assert.match(source, /row-resize/); + assert.match(source, /startHeight \+ \(startY - e\.clientY\)/); + assert.match(source, /style:height=\{workspace\.panelOpen/); +}); diff --git a/src/components/scripts/ScriptReferenceModal.svelte b/src/components/scripts/ScriptReferenceModal.svelte new file mode 100644 index 0000000..fcece78 --- /dev/null +++ b/src/components/scripts/ScriptReferenceModal.svelte @@ -0,0 +1,168 @@ + + + + + + diff --git a/src/components/scripts/ScriptReferenceModal.test.js b/src/components/scripts/ScriptReferenceModal.test.js new file mode 100644 index 0000000..67b5336 --- /dev/null +++ b/src/components/scripts/ScriptReferenceModal.test.js @@ -0,0 +1,25 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./ScriptReferenceModal.svelte', import.meta.url), 'utf8'); + +test('extract mode renders an editable prefilled path', () => { + expect(source).toContain("mode: 'extract' | 'reference'"); + expect(source).toContain('bind:value={path}'); + expect(source).toContain('fileExists'); + expect(source).toContain("'更新脚本文件'"); + expect(source).toContain("'提取脚本到文件'"); + expect(source).toContain('完整路径'); +}); + +test('reference mode renders selectable script files and an empty state', () => { + expect(source).toContain('{#each files as file (file.path)}'); + expect(source).toContain('type="radio"'); + expect(source).toContain('暂无可引用的脚本文件'); +}); + +test('modal validates js paths and supports cancellation', () => { + expect(source).toContain("path.trim().endsWith('.js')"); + expect(source).toContain("event.key === 'Escape'"); + expect(source).toContain('disabled={!canSubmit}'); +}); diff --git a/src/components/scripts/ScriptTree.svelte b/src/components/scripts/ScriptTree.svelte new file mode 100644 index 0000000..5439de9 --- /dev/null +++ b/src/components/scripts/ScriptTree.svelte @@ -0,0 +1,224 @@ + + + + + diff --git a/src/components/scripts/ScriptTree.test.js b/src/components/scripts/ScriptTree.test.js new file mode 100644 index 0000000..c84acd7 --- /dev/null +++ b/src/components/scripts/ScriptTree.test.js @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./ScriptTree.svelte', import.meta.url), 'utf8'); + +test('every tree row carries a data-path attribute and a tree-delete button', () => { + assert.match(source, /data-path=\{node\.path\}/); + assert.match(source, /class="tree-delete"/); +}); + +test('the delete button is a real + + + {:else} + {#if !config.addr}

未配置

{/if} +
+ + +
{#if config.addr}{/if}
+
+ {/if} + {:else if loading}

正在读取配置…

{/if} + + + diff --git a/src/components/settings/CapabilityGatewayPanel.test.ts b/src/components/settings/CapabilityGatewayPanel.test.ts new file mode 100644 index 0000000..15e7541 --- /dev/null +++ b/src/components/settings/CapabilityGatewayPanel.test.ts @@ -0,0 +1,135 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import CapabilityGatewayPanel from './CapabilityGatewayPanel.svelte'; + +const mocks = vi.hoisted(() => ({ + settingsService: { + getCapabilityGatewayConfig: vi.fn(), + updateCapabilityGatewayConfig: vi.fn(), + }, + capabilityService: { getCapabilityStatus: vi.fn() }, +})); + +vi.mock('../../lib/rpc', () => ({ + settingsService: mocks.settingsService, + capabilityService: mocks.capabilityService, +})); + +describe('CapabilityGatewayPanel', () => { + beforeEach(() => vi.resetAllMocks()); + + test('shows configuration instead of a zero service count when gateway is unconfigured', async () => { + mocks.settingsService.getCapabilityGatewayConfig.mockResolvedValue({ config: { addr: '', tokenSet: false } }); + mocks.capabilityService.getCapabilityStatus.mockResolvedValue({ configured: false, ok: false, status: 'not_configured', serviceCount: 0 }); + render(CapabilityGatewayPanel); + expect(await screen.findByText('未配置')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: '能力网关' })).toBeInTheDocument(); + expect(screen.queryByText('CAPABILITY GATEWAY')).not.toBeInTheDocument(); + expect(screen.queryByText('0 个服务')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Gateway 地址')).toBeInTheDocument(); + }); + + test('shows address and service count without exposing an unreliable token state', async () => { + mocks.settingsService.getCapabilityGatewayConfig.mockResolvedValue({ config: { addr: 'https://octobus.example', tokenSet: true } }); + mocks.capabilityService.getCapabilityStatus.mockResolvedValue({ + configured: true, ok: true, status: 'ready', serviceCount: 7, + runtimeConfigured: true, proxyListenConfigured: true, proxyTargetConfigured: true, + }); + render(CapabilityGatewayPanel); + expect(await screen.findByText('https://octobus.example')).toBeInTheDocument(); + const overview = screen.getByTestId('gateway-overview'); + expect(screen.getByTestId('gateway-address-row')).toHaveTextContent(/^Gateway 地址https:\/\/octobus\.example$/); + const statusRow = screen.getByTestId('gateway-status-row'); + expect(statusRow).toHaveTextContent(/连接正常.*运行时可用.*监听已配置.*目标已配置.*7 个服务.*编辑配置/); + expect(statusRow).not.toHaveTextContent(/令牌(?:已|未)设置/); + expect(within(overview).getByRole('img', { name: '连接正常' })).toBeInTheDocument(); + expect(within(overview).getByText('连接正常')).toBeInTheDocument(); + expect(within(overview).getByRole('button', { name: '编辑配置' })).toBeInTheDocument(); + expect(within(overview).queryByRole('button', { name: '更换现有令牌' })).not.toBeInTheDocument(); + expect(within(overview).getByText('运行时可用')).toBeInTheDocument(); + expect(within(overview).getByText('监听已配置')).toBeInTheDocument(); + expect(within(overview).getByText('目标已配置')).toBeInTheDocument(); + expect(overview.querySelector('.runtime-warning')).not.toBeInTheDocument(); + }); + + test('shows backend connection errors and runtime proxy availability without a misleading service count', async () => { + mocks.settingsService.getCapabilityGatewayConfig.mockResolvedValue({ config: { addr: 'https://octobus.example', tokenSet: true } }); + mocks.capabilityService.getCapabilityStatus.mockResolvedValue({ + configured: true, + ok: false, + status: 'unreachable', + error: 'dial tcp: connection refused', + serviceCount: 9, + runtimeConfigured: false, + proxyListenConfigured: true, + proxyTargetConfigured: false, + }); + render(CapabilityGatewayPanel); + expect(await screen.findByRole('img', { name: '连接异常' })).toBeInTheDocument(); + expect(screen.getByText('dial tcp: connection refused')).toBeInTheDocument(); + expect(screen.queryByText('9 个服务')).not.toBeInTheDocument(); + const statusRow = screen.getByTestId('gateway-status-row'); + expect(statusRow).toHaveTextContent(/连接异常.*运行时不可用.*监听已配置.*目标未配置/); + expect(statusRow).not.toHaveTextContent(/令牌(?:已|未)设置/); + expect(statusRow.querySelector('.runtime-warning')).not.toBeInTheDocument(); + }); + + test('saves the first address and token', async () => { + mocks.settingsService.getCapabilityGatewayConfig + .mockResolvedValueOnce({ config: { addr: '', tokenSet: false } }) + .mockResolvedValueOnce({ config: { addr: 'https://octobus.example', tokenSet: true } }); + mocks.capabilityService.getCapabilityStatus + .mockResolvedValueOnce({ configured: false, ok: false, status: 'not_configured', serviceCount: 0 }) + .mockResolvedValueOnce({ configured: true, ok: true, status: 'ready', serviceCount: 1 }); + mocks.settingsService.updateCapabilityGatewayConfig.mockResolvedValue({}); + render(CapabilityGatewayPanel); + await fireEvent.input(await screen.findByLabelText('Gateway 地址'), { target: { value: 'https://octobus.example' } }); + await fireEvent.input(screen.getByLabelText('Gateway 令牌'), { target: { value: 'first-token' } }); + await fireEvent.click(screen.getByRole('button', { name: '保存配置' })); + await waitFor(() => expect(mocks.settingsService.updateCapabilityGatewayConfig).toHaveBeenCalledWith( + expect.objectContaining({ addr: 'https://octobus.example', token: 'first-token' }), + )); + }); + + test('rejects a non-http gateway address', async () => { + mocks.settingsService.getCapabilityGatewayConfig.mockResolvedValue({ config: { addr: '', tokenSet: false } }); + mocks.capabilityService.getCapabilityStatus.mockResolvedValue({ configured: false, ok: false, status: 'not_configured', serviceCount: 0 }); + render(CapabilityGatewayPanel); + await fireEvent.input(await screen.findByLabelText('Gateway 地址'), { target: { value: 'ftp://invalid.example' } }); + await fireEvent.click(screen.getByRole('button', { name: '保存配置' })); + expect(await screen.findByText('地址必须使用 http:// 或 https://')).toBeInTheDocument(); + expect(mocks.settingsService.updateCapabilityGatewayConfig).not.toHaveBeenCalled(); + }); + + test('replaces an existing token through edit configuration', async () => { + mocks.settingsService.getCapabilityGatewayConfig.mockResolvedValue({ config: { addr: 'https://octobus.example', tokenSet: true } }); + mocks.capabilityService.getCapabilityStatus.mockResolvedValue({ configured: true, ok: true, status: 'ready', serviceCount: 7 }); + mocks.settingsService.updateCapabilityGatewayConfig.mockResolvedValue({}); + render(CapabilityGatewayPanel); + await fireEvent.click(await screen.findByRole('button', { name: '编辑配置' })); + await fireEvent.input(screen.getByLabelText('Gateway 令牌'), { target: { value: 'replacement-token' } }); + await fireEvent.click(screen.getByRole('button', { name: '保存配置' })); + await waitFor(() => expect(mocks.settingsService.updateCapabilityGatewayConfig).toHaveBeenCalledWith( + expect.objectContaining({ addr: 'https://octobus.example', token: 'replacement-token' }), + )); + expect(mocks.settingsService.updateCapabilityGatewayConfig.mock.calls[0][0].token).toBe('replacement-token'); + }); + + test('reports configuration state before the saved callback after refresh', async () => { + const order: string[] = []; + mocks.settingsService.getCapabilityGatewayConfig + .mockResolvedValueOnce({ config: { addr: '', tokenSet: false } }) + .mockResolvedValueOnce({ config: { addr: 'https://octobus.example', tokenSet: false } }); + mocks.capabilityService.getCapabilityStatus.mockResolvedValue({ configured: true, ok: true, serviceCount: 1 }); + mocks.settingsService.updateCapabilityGatewayConfig.mockResolvedValue({}); + render(CapabilityGatewayPanel, { + onconfigurationchange: (configured) => order.push(`configured:${configured}`), + onconfigured: () => order.push('saved'), + }); + await fireEvent.input(await screen.findByLabelText('Gateway 地址'), { target: { value: 'https://octobus.example' } }); + await fireEvent.click(screen.getByRole('button', { name: '保存配置' })); + await waitFor(() => expect(order).toEqual(['configured:false', 'configured:true', 'saved'])); + expect(mocks.settingsService.getCapabilityGatewayConfig).toHaveBeenCalledTimes(2); + expect(mocks.capabilityService.getCapabilityStatus).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/settings/GlobalEnvPanel.svelte b/src/components/settings/GlobalEnvPanel.svelte new file mode 100644 index 0000000..9d76086 --- /dev/null +++ b/src/components/settings/GlobalEnvPanel.svelte @@ -0,0 +1,205 @@ + + +
+
+
+

全局环境变量

+
+ +
+ {#if error}{/if} +
+ {#each rows as row (row.id)} +
+ {row.name}{#if row.secret}SECRET{/if} + {row.secret ? '••••••••' : (row.value || '—')} +
+ {:else} +

尚无全局环境变量。

+ {/each} +
+
+ +{#if dialogOpen} + +
+

修改环境变量

+ +
+
+ {#if editorError}{/if} +
+ {#each editorRows as row, index (row.id)} +
+ + + + +
+ {/each} +
+ +
+
+ +
+
+{/if} + + diff --git a/src/components/settings/GlobalEnvPanel.test.ts b/src/components/settings/GlobalEnvPanel.test.ts new file mode 100644 index 0000000..73a4b48 --- /dev/null +++ b/src/components/settings/GlobalEnvPanel.test.ts @@ -0,0 +1,200 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import GlobalEnvPanel from './GlobalEnvPanel.svelte'; +import componentSource from './GlobalEnvPanel.svelte?raw'; + +const mocks = vi.hoisted(() => ({ settingsService: { getGlobalEnv: vi.fn(), updateGlobalEnv: vi.fn() } })); +vi.mock('../../lib/rpc', () => ({ settingsService: mocks.settingsService })); + +beforeEach(() => vi.clearAllMocks()); + +const loaded = [ + { name: 'REGION', value: 'east', secret: false }, + { name: 'ZONE', value: 'one', secret: false }, + { name: 'TOKEN', value: '', secret: true }, +]; + +async function openEditor() { + const modify = await screen.findByRole('button', { name: '修改变量' }); + await fireEvent.click(modify); + return screen.getByRole('dialog', { name: '环境变量' }); +} + +test('offers one entry action and opens every loaded row safely', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: loaded }); + render(GlobalEnvPanel); + const modify = await screen.findByRole('button', { name: '修改变量' }); + expect(screen.getByRole('heading', { name: '全局环境变量' })).toBeInTheDocument(); + expect(screen.queryByText('DAEMON SCOPE')).not.toBeInTheDocument(); + expect(modify).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '新增变量' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '保存环境变量' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^编辑 / })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^删除 / })).not.toBeInTheDocument(); + await fireEvent.click(modify); + expect(screen.getByLabelText('变量名称 1')).toHaveValue('REGION'); + expect(screen.getByLabelText('变量值 1')).toHaveValue('east'); + expect(screen.getByLabelText('敏感变量 1')).not.toBeChecked(); + expect(screen.getByRole('button', { name: '删除变量 1' })).toBeInTheDocument(); + expect(screen.getByLabelText('变量名称 3')).toHaveValue('TOKEN'); + expect(screen.getByLabelText('变量值 3')).toHaveValue(''); + expect(screen.getByLabelText('敏感变量 3')).toBeChecked(); +}); + +test('shows non-secret values in the read-only list and masks stored secrets', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: [ + ...loaded.slice(0, 2), + { name: 'TOKEN', value: 'server-secret-must-not-render', secret: true }, + ] }); + render(GlobalEnvPanel); + + expect(await screen.findByText('east')).toBeInTheDocument(); + expect(screen.getByText('one')).toBeInTheDocument(); + expect(screen.getByText('••••••••')).toBeInTheDocument(); + expect(screen.queryByText('server-secret-must-not-render')).not.toBeInTheDocument(); +}); + +test('appends an empty row from the dialog add button', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: [loaded[0]] }); + render(GlobalEnvPanel); + const dialog = await openEditor(); + expect(dialog).toHaveClass('viewport-modal'); + await fireEvent.click(within(dialog).getByRole('button', { name: '新增变量' })); + expect(screen.getByLabelText('变量名称 2')).toHaveValue(''); + expect(screen.getByLabelText('变量值 2')).toHaveValue(''); +}); + +test('places cancel in the header, add below the rows, and only save in the footer', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: [loaded[0]] }); + render(GlobalEnvPanel); + const dialog = await openEditor(); + const header = within(dialog).getByRole('banner'); + const content = within(dialog).getByTestId('environment-editor-content'); + const footer = within(dialog).getByRole('contentinfo'); + + expect(within(header).getByRole('button', { name: '取消本次编辑' })).toHaveTextContent('×'); + expect(within(content).getByRole('button', { name: '新增变量' })).toHaveTextContent('+'); + expect(within(footer).getAllByRole('button')).toHaveLength(1); + expect(within(footer).getByRole('button', { name: '保存' })).toBeInTheDocument(); + expect(within(footer).queryByRole('button', { name: '取消' })).not.toBeInTheDocument(); +}); + +test('keeps the dialog viewport-centered and its narrow header horizontally aligned', () => { + expect(componentSource).toMatch(/dialog\.viewport-modal\{[^}]*position:fixed;[^}]*inset:0;[^}]*margin:auto;/); + expect(componentSource).toMatch(/@media\(max-width:600px\)\{\.panel > header\{[^}]*flex-direction:column[^}]*\}[^}]*\.dialog-title\{[^}]*flex-direction:row;[^}]*justify-content:space-between;/); +}); + +test('contains long values and progressively reflows editor rows on narrow viewports', () => { + expect(componentSource).toMatch(/dialog\.viewport-modal\{[^}]*box-sizing:border-box/); + expect(componentSource).toMatch(/dialog input\{[^}]*box-sizing:border-box;[^}]*width:100%;[^}]*min-width:0/); + expect(componentSource).toMatch(/\.row\{[^}]*min-width:0/); + expect(componentSource).toMatch(/\.name\{[^}]*min-width:0;[^}]*overflow:hidden;[^}]*text-overflow:ellipsis;[^}]*white-space:nowrap/); + expect(componentSource).toMatch(/\.value\{[^}]*flex:1 1 auto;[^}]*min-width:0/); + expect(componentSource).toMatch(/\.editor-row>\*\{[^}]*min-width:0/); + expect(componentSource).toMatch(/@media\(max-width:900px\)[\s\S]*\.editor-row\{[^}]*grid-template-columns:minmax\(0,1fr\) minmax\(0,1fr\)/); + expect(componentSource).toMatch(/@media\(max-width:600px\)[\s\S]*\.editor-row\{[^}]*grid-template-columns:minmax\(0,1fr\)/); +}); + +test('saves two inline modifications in one replacement request', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: loaded }); + mocks.settingsService.updateGlobalEnv.mockResolvedValue({ env: [ + { ...loaded[0], value: 'west' }, { ...loaded[1], value: 'two' }, loaded[2], + ] }); + render(GlobalEnvPanel); + await openEditor(); + await fireEvent.input(screen.getByLabelText('变量值 1'), { target: { value: 'west' } }); + await fireEvent.input(screen.getByLabelText('变量值 2'), { target: { value: 'two' } }); + await fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.settingsService.updateGlobalEnv).toHaveBeenCalledTimes(1)); + expect(mocks.settingsService.updateGlobalEnv.mock.calls[0][0].env).toEqual([ + expect.objectContaining({ name: 'REGION', value: 'west', secret: false }), + expect.objectContaining({ name: 'ZONE', value: 'two', secret: false }), + expect.objectContaining({ name: 'TOKEN', value: undefined, secret: true }), + ]); + expect(screen.queryByRole('dialog', { name: '环境变量' })).not.toBeInTheDocument(); +}); + +test('deletes only from the modal draft and omits the row on save', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: loaded }); + mocks.settingsService.updateGlobalEnv.mockResolvedValue({ env: loaded.slice(1) }); + render(GlobalEnvPanel); + await openEditor(); + await fireEvent.click(screen.getByRole('button', { name: '删除变量 1' })); + await fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => expect(mocks.settingsService.updateGlobalEnv).toHaveBeenCalled()); + expect(mocks.settingsService.updateGlobalEnv.mock.calls[0][0].env.map((row: { name: string }) => row.name)).toEqual(['ZONE', 'TOKEN']); +}); + +test('discards add, edit, and delete drafts from the header cancel action', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: loaded }); + render(GlobalEnvPanel); + await openEditor(); + await fireEvent.input(screen.getByLabelText('变量值 1'), { target: { value: 'changed' } }); + await fireEvent.click(screen.getByRole('button', { name: '删除变量 2' })); + await fireEvent.click(screen.getByRole('button', { name: '新增变量' })); + await fireEvent.click(screen.getByRole('button', { name: '取消本次编辑' })); + await openEditor(); + expect(screen.getByLabelText('变量值 1')).toHaveValue('east'); + expect(screen.getByLabelText('变量名称 2')).toHaveValue('ZONE'); + expect(screen.getByLabelText('变量名称 3')).toHaveValue('TOKEN'); + expect(mocks.settingsService.updateGlobalEnv).not.toHaveBeenCalled(); +}); + +test('dialog cancel event discards drafts and restores trigger focus', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: loaded }); + render(GlobalEnvPanel); + const trigger = await screen.findByRole('button', { name: '修改变量' }); + trigger.focus(); + const dialog = await openEditor(); + expect(screen.getByLabelText('变量名称 1')).toHaveFocus(); + await fireEvent.input(screen.getByLabelText('变量值 1'), { target: { value: 'changed' } }); + await fireEvent(dialog, new Event('cancel', { cancelable: true })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: '环境变量' })).not.toBeInTheDocument()); + expect(trigger).toHaveFocus(); + await fireEvent.click(trigger); + expect(screen.getByLabelText('变量值 1')).toHaveValue('east'); +}); + +test('keeps every inline draft in the open dialog when save fails', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: loaded }); + mocks.settingsService.updateGlobalEnv.mockRejectedValue(new Error('save failed')); + render(GlobalEnvPanel); + await openEditor(); + await fireEvent.input(screen.getByLabelText('变量值 1'), { target: { value: 'changed' } }); + await fireEvent.click(screen.getByRole('button', { name: '删除变量 2' })); + await fireEvent.click(screen.getByRole('button', { name: '新增变量' })); + await fireEvent.input(screen.getByLabelText('变量名称 3'), { target: { value: 'EXTRA' } }); + await fireEvent.input(screen.getByLabelText('变量值 3'), { target: { value: 'new' } }); + await fireEvent.click(screen.getByRole('button', { name: '保存' })); + expect(await screen.findByRole('alert')).toHaveTextContent('save failed'); + expect(screen.getByRole('dialog', { name: '环境变量' })).toBeInTheDocument(); + expect(screen.getByLabelText('变量值 1')).toHaveValue('changed'); + expect(screen.getByLabelText('变量名称 2')).toHaveValue('TOKEN'); + expect(screen.getByLabelText('变量名称 3')).toHaveValue('EXTRA'); +}); + +test.each([ + ['blank name', '变量名称 1', ' ', '变量名称不能为空'], + ['duplicate name', '变量名称 2', 'REGION', '变量名称不能重复'], + ['new blank value', 'new', '', '变量值不能为空'], +])('validates the entire draft: %s', async (_case, field, value, message) => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: loaded.slice(0, 2) }); + render(GlobalEnvPanel); + await openEditor(); + if (field === 'new') { + await fireEvent.click(screen.getByRole('button', { name: '新增变量' })); + await fireEvent.input(screen.getByLabelText('变量名称 3'), { target: { value: 'EXTRA' } }); + } else await fireEvent.input(screen.getByLabelText(field), { target: { value } }); + await fireEvent.click(screen.getByRole('button', { name: '保存' })); + expect(screen.getByRole('alert')).toHaveTextContent(message); + expect(mocks.settingsService.updateGlobalEnv).not.toHaveBeenCalled(); +}); + +test('requires a replacement when a stored secret identity or kind changes', async () => { + mocks.settingsService.getGlobalEnv.mockResolvedValue({ env: [loaded[2]] }); + render(GlobalEnvPanel); + await openEditor(); + await fireEvent.input(screen.getByLabelText('变量名称 1'), { target: { value: 'RENAMED' } }); + await fireEvent.click(screen.getByRole('button', { name: '保存' })); + expect(screen.getByRole('alert')).toHaveTextContent('变量值不能为空'); +}); diff --git a/src/gen/agentcompose/v1/agentcompose_connect.ts b/src/gen/agentcompose/v1/agentcompose_connect.ts new file mode 100644 index 0000000..ceb4500 --- /dev/null +++ b/src/gen/agentcompose/v1/agentcompose_connect.ts @@ -0,0 +1,512 @@ +// @generated by protoc-gen-connect-es v1.7.0 with parameter "target=ts,import_extension=.js" +// @generated from file agentcompose/v1/agentcompose.proto (package agentcompose.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { AgentDefinitionIDRequest, AgentDefinitionResponse, CapabilityGatewayConfig, CapabilityStatusResponse, CreateAgentDefinitionRequest, CreateAgentSessionRequest, CreateLoaderRequest, CreateSessionRequest, CreateWorkspaceConfigRequest, DashboardOverviewEvent, DashboardOverviewResponse, ExecuteCellRequest, ExecuteCellResponse, ExecuteCellStreamResponse, GenerateLLMRequest, GenerateLLMResponse, GetCapabilityCatalogRequest, GetCapabilityCatalogResponse, GetCapabilityStatusRequest, GlobalEnvConfigResponse, ListAgentDefinitionsRequest, ListAgentDefinitionsResponse, ListCapabilitySetsRequest, ListCapabilitySetsResponse, ListCellsResponse, ListLoaderEventsRequest, ListLoaderEventsResponse, ListLoaderRunsRequest, ListLoaderRunsResponse, ListLoadersResponse, ListSessionEventsResponse, ListSessionsRequest, ListSessionsResponse, ListWorkspaceConfigsResponse, LoaderIDRequest, LoaderResponse, LoaderRunIDRequest, LoaderRunResponse, RunLoaderNowRequest, SendAgentMessageRequest, SendAgentMessageResponse, SendAgentMessageStreamResponse, SessionIDRequest, SessionProxyResponse, SessionResponse, SetAgentDefinitionEnabledRequest, SetLoaderEnabledRequest, SetLoaderTriggerEnabledRequest, UpdateAgentDefinitionRequest, UpdateCapabilityGatewayConfigRequest, UpdateGlobalEnvConfigRequest, UpdateLoaderRequest, UpdateWorkspaceConfigRequest, ValidateAgentDefinitionRequest, ValidateAgentDefinitionResponse, ValidateLoaderRequest, ValidateLoaderResponse, WatchSessionResponse, WorkspaceConfigIDRequest, WorkspaceConfigResponse } from "./agentcompose_pb.js"; +import { Empty, MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service agentcompose.v1.SessionService + */ +export const SessionService = { + typeName: "agentcompose.v1.SessionService", + methods: { + /** + * @generated from rpc agentcompose.v1.SessionService.CreateSession + */ + createSession: { + name: "CreateSession", + I: CreateSessionRequest, + O: SessionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.SessionService.ResumeSession + */ + resumeSession: { + name: "ResumeSession", + I: SessionIDRequest, + O: SessionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.SessionService.StopSession + */ + stopSession: { + name: "StopSession", + I: SessionIDRequest, + O: SessionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.SessionService.GetSession + */ + getSession: { + name: "GetSession", + I: SessionIDRequest, + O: SessionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.SessionService.ListSessions + */ + listSessions: { + name: "ListSessions", + I: ListSessionsRequest, + O: ListSessionsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.SessionService.GetSessionProxy + */ + getSessionProxy: { + name: "GetSessionProxy", + I: SessionIDRequest, + O: SessionProxyResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.SessionService.WatchSession + */ + watchSession: { + name: "WatchSession", + I: SessionIDRequest, + O: WatchSessionResponse, + kind: MethodKind.ServerStreaming, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.KernelService + */ +export const KernelService = { + typeName: "agentcompose.v1.KernelService", + methods: { + /** + * @generated from rpc agentcompose.v1.KernelService.ExecuteCell + */ + executeCell: { + name: "ExecuteCell", + I: ExecuteCellRequest, + O: ExecuteCellResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.KernelService.ExecuteCellStream + */ + executeCellStream: { + name: "ExecuteCellStream", + I: ExecuteCellRequest, + O: ExecuteCellStreamResponse, + kind: MethodKind.ServerStreaming, + }, + /** + * @generated from rpc agentcompose.v1.KernelService.ListCells + */ + listCells: { + name: "ListCells", + I: SessionIDRequest, + O: ListCellsResponse, + kind: MethodKind.Unary, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.AgentService + */ +export const AgentService = { + typeName: "agentcompose.v1.AgentService", + methods: { + /** + * @generated from rpc agentcompose.v1.AgentService.SendAgentMessage + */ + sendAgentMessage: { + name: "SendAgentMessage", + I: SendAgentMessageRequest, + O: SendAgentMessageResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentService.SendAgentMessageStream + */ + sendAgentMessageStream: { + name: "SendAgentMessageStream", + I: SendAgentMessageRequest, + O: SendAgentMessageStreamResponse, + kind: MethodKind.ServerStreaming, + }, + /** + * @generated from rpc agentcompose.v1.AgentService.ListSessionEvents + */ + listSessionEvents: { + name: "ListSessionEvents", + I: SessionIDRequest, + O: ListSessionEventsResponse, + kind: MethodKind.Unary, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.AgentDefinitionService + */ +export const AgentDefinitionService = { + typeName: "agentcompose.v1.AgentDefinitionService", + methods: { + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.ListAgentDefinitions + */ + listAgentDefinitions: { + name: "ListAgentDefinitions", + I: ListAgentDefinitionsRequest, + O: ListAgentDefinitionsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.GetAgentDefinition + */ + getAgentDefinition: { + name: "GetAgentDefinition", + I: AgentDefinitionIDRequest, + O: AgentDefinitionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.CreateAgentDefinition + */ + createAgentDefinition: { + name: "CreateAgentDefinition", + I: CreateAgentDefinitionRequest, + O: AgentDefinitionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.UpdateAgentDefinition + */ + updateAgentDefinition: { + name: "UpdateAgentDefinition", + I: UpdateAgentDefinitionRequest, + O: AgentDefinitionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.DeleteAgentDefinition + */ + deleteAgentDefinition: { + name: "DeleteAgentDefinition", + I: AgentDefinitionIDRequest, + O: Empty, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.SetAgentDefinitionEnabled + */ + setAgentDefinitionEnabled: { + name: "SetAgentDefinitionEnabled", + I: SetAgentDefinitionEnabledRequest, + O: AgentDefinitionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.ValidateAgentDefinition + */ + validateAgentDefinition: { + name: "ValidateAgentDefinition", + I: ValidateAgentDefinitionRequest, + O: ValidateAgentDefinitionResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.AgentDefinitionService.CreateAgentSession + */ + createAgentSession: { + name: "CreateAgentSession", + I: CreateAgentSessionRequest, + O: SessionResponse, + kind: MethodKind.Unary, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.LLMService + */ +export const LLMService = { + typeName: "agentcompose.v1.LLMService", + methods: { + /** + * @generated from rpc agentcompose.v1.LLMService.Generate + */ + generate: { + name: "Generate", + I: GenerateLLMRequest, + O: GenerateLLMResponse, + kind: MethodKind.Unary, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.ConfigService + */ +export const ConfigService = { + typeName: "agentcompose.v1.ConfigService", + methods: { + /** + * @generated from rpc agentcompose.v1.ConfigService.GetGlobalEnvConfig + */ + getGlobalEnvConfig: { + name: "GetGlobalEnvConfig", + I: Empty, + O: GlobalEnvConfigResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.ConfigService.UpdateGlobalEnvConfig + */ + updateGlobalEnvConfig: { + name: "UpdateGlobalEnvConfig", + I: UpdateGlobalEnvConfigRequest, + O: GlobalEnvConfigResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.ConfigService.GetCapabilityGatewayConfig + */ + getCapabilityGatewayConfig: { + name: "GetCapabilityGatewayConfig", + I: Empty, + O: CapabilityGatewayConfig, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.ConfigService.UpdateCapabilityGatewayConfig + */ + updateCapabilityGatewayConfig: { + name: "UpdateCapabilityGatewayConfig", + I: UpdateCapabilityGatewayConfigRequest, + O: CapabilityGatewayConfig, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.ConfigService.ListWorkspaceConfigs + */ + listWorkspaceConfigs: { + name: "ListWorkspaceConfigs", + I: Empty, + O: ListWorkspaceConfigsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.ConfigService.CreateWorkspaceConfig + */ + createWorkspaceConfig: { + name: "CreateWorkspaceConfig", + I: CreateWorkspaceConfigRequest, + O: WorkspaceConfigResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.ConfigService.UpdateWorkspaceConfig + */ + updateWorkspaceConfig: { + name: "UpdateWorkspaceConfig", + I: UpdateWorkspaceConfigRequest, + O: WorkspaceConfigResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.ConfigService.DeleteWorkspaceConfig + */ + deleteWorkspaceConfig: { + name: "DeleteWorkspaceConfig", + I: WorkspaceConfigIDRequest, + O: Empty, + kind: MethodKind.Unary, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.LoaderService + */ +export const LoaderService = { + typeName: "agentcompose.v1.LoaderService", + methods: { + /** + * @generated from rpc agentcompose.v1.LoaderService.ValidateLoader + */ + validateLoader: { + name: "ValidateLoader", + I: ValidateLoaderRequest, + O: ValidateLoaderResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.ListLoaders + */ + listLoaders: { + name: "ListLoaders", + I: Empty, + O: ListLoadersResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.GetLoader + */ + getLoader: { + name: "GetLoader", + I: LoaderIDRequest, + O: LoaderResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.CreateLoader + */ + createLoader: { + name: "CreateLoader", + I: CreateLoaderRequest, + O: LoaderResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.UpdateLoader + */ + updateLoader: { + name: "UpdateLoader", + I: UpdateLoaderRequest, + O: LoaderResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.DeleteLoader + */ + deleteLoader: { + name: "DeleteLoader", + I: LoaderIDRequest, + O: Empty, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.SetLoaderEnabled + */ + setLoaderEnabled: { + name: "SetLoaderEnabled", + I: SetLoaderEnabledRequest, + O: LoaderResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.SetLoaderTriggerEnabled + */ + setLoaderTriggerEnabled: { + name: "SetLoaderTriggerEnabled", + I: SetLoaderTriggerEnabledRequest, + O: LoaderResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.RunLoaderNow + */ + runLoaderNow: { + name: "RunLoaderNow", + I: RunLoaderNowRequest, + O: LoaderRunResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.ListLoaderRuns + */ + listLoaderRuns: { + name: "ListLoaderRuns", + I: ListLoaderRunsRequest, + O: ListLoaderRunsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.GetLoaderRun + */ + getLoaderRun: { + name: "GetLoaderRun", + I: LoaderRunIDRequest, + O: LoaderRunResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.LoaderService.ListLoaderEvents + */ + listLoaderEvents: { + name: "ListLoaderEvents", + I: ListLoaderEventsRequest, + O: ListLoaderEventsResponse, + kind: MethodKind.Unary, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.DashboardService + */ +export const DashboardService = { + typeName: "agentcompose.v1.DashboardService", + methods: { + /** + * @generated from rpc agentcompose.v1.DashboardService.GetDashboardOverview + */ + getDashboardOverview: { + name: "GetDashboardOverview", + I: Empty, + O: DashboardOverviewResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.DashboardService.WatchDashboardOverview + */ + watchDashboardOverview: { + name: "WatchDashboardOverview", + I: Empty, + O: DashboardOverviewEvent, + kind: MethodKind.ServerStreaming, + }, + } +} as const; + +/** + * @generated from service agentcompose.v1.CapabilityService + */ +export const CapabilityService = { + typeName: "agentcompose.v1.CapabilityService", + methods: { + /** + * @generated from rpc agentcompose.v1.CapabilityService.GetCapabilityStatus + */ + getCapabilityStatus: { + name: "GetCapabilityStatus", + I: GetCapabilityStatusRequest, + O: CapabilityStatusResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.CapabilityService.ListCapabilitySets + */ + listCapabilitySets: { + name: "ListCapabilitySets", + I: ListCapabilitySetsRequest, + O: ListCapabilitySetsResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc agentcompose.v1.CapabilityService.GetCapabilityCatalog + */ + getCapabilityCatalog: { + name: "GetCapabilityCatalog", + I: GetCapabilityCatalogRequest, + O: GetCapabilityCatalogResponse, + kind: MethodKind.Unary, + }, + } +} as const; + diff --git a/src/gen/agentcompose/v1/agentcompose_pb.ts b/src/gen/agentcompose/v1/agentcompose_pb.ts new file mode 100644 index 0000000..45a8e4a --- /dev/null +++ b/src/gen/agentcompose/v1/agentcompose_pb.ts @@ -0,0 +1,5356 @@ +// @generated by protoc-gen-es v1.10.1 with parameter "target=ts,import_extension=.js" +// @generated from file agentcompose/v1/agentcompose.proto (package agentcompose.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3, protoInt64 } from "@bufbuild/protobuf"; + +/** + * @generated from enum agentcompose.v1.CellType + */ +export enum CellType { + /** + * @generated from enum value: CELL_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CELL_TYPE_SHELL = 1; + */ + SHELL = 1, + + /** + * @generated from enum value: CELL_TYPE_JAVASCRIPT = 2; + */ + JAVASCRIPT = 2, + + /** + * @generated from enum value: CELL_TYPE_PYTHON = 3; + */ + PYTHON = 3, + + /** + * @generated from enum value: CELL_TYPE_AGENT = 4; + */ + AGENT = 4, +} +// Retrieve enum metadata with: proto3.getEnumType(CellType) +proto3.util.setEnumType(CellType, "agentcompose.v1.CellType", [ + { no: 0, name: "CELL_TYPE_UNSPECIFIED" }, + { no: 1, name: "CELL_TYPE_SHELL" }, + { no: 2, name: "CELL_TYPE_JAVASCRIPT" }, + { no: 3, name: "CELL_TYPE_PYTHON" }, + { no: 4, name: "CELL_TYPE_AGENT" }, +]); + +/** + * @generated from enum agentcompose.v1.ExecuteCellStreamEventType + */ +export enum ExecuteCellStreamEventType { + /** + * @generated from enum value: EXECUTE_CELL_STREAM_EVENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: EXECUTE_CELL_STREAM_EVENT_TYPE_STARTED = 1; + */ + STARTED = 1, + + /** + * @generated from enum value: EXECUTE_CELL_STREAM_EVENT_TYPE_OUTPUT = 2; + */ + OUTPUT = 2, + + /** + * @generated from enum value: EXECUTE_CELL_STREAM_EVENT_TYPE_COMPLETED = 3; + */ + COMPLETED = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(ExecuteCellStreamEventType) +proto3.util.setEnumType(ExecuteCellStreamEventType, "agentcompose.v1.ExecuteCellStreamEventType", [ + { no: 0, name: "EXECUTE_CELL_STREAM_EVENT_TYPE_UNSPECIFIED" }, + { no: 1, name: "EXECUTE_CELL_STREAM_EVENT_TYPE_STARTED" }, + { no: 2, name: "EXECUTE_CELL_STREAM_EVENT_TYPE_OUTPUT" }, + { no: 3, name: "EXECUTE_CELL_STREAM_EVENT_TYPE_COMPLETED" }, +]); + +/** + * @generated from enum agentcompose.v1.SendAgentMessageStreamEventType + */ +export enum SendAgentMessageStreamEventType { + /** + * @generated from enum value: SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_STARTED = 1; + */ + STARTED = 1, + + /** + * @generated from enum value: SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_OUTPUT = 2; + */ + OUTPUT = 2, + + /** + * @generated from enum value: SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_COMPLETED = 3; + */ + COMPLETED = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(SendAgentMessageStreamEventType) +proto3.util.setEnumType(SendAgentMessageStreamEventType, "agentcompose.v1.SendAgentMessageStreamEventType", [ + { no: 0, name: "SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_UNSPECIFIED" }, + { no: 1, name: "SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_STARTED" }, + { no: 2, name: "SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_OUTPUT" }, + { no: 3, name: "SEND_AGENT_MESSAGE_STREAM_EVENT_TYPE_COMPLETED" }, +]); + +/** + * @generated from enum agentcompose.v1.WatchSessionEventType + */ +export enum WatchSessionEventType { + /** + * @generated from enum value: WATCH_SESSION_EVENT_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: WATCH_SESSION_EVENT_TYPE_SESSION_UPDATED = 1; + */ + SESSION_UPDATED = 1, + + /** + * @generated from enum value: WATCH_SESSION_EVENT_TYPE_CELL_STARTED = 2; + */ + CELL_STARTED = 2, + + /** + * @generated from enum value: WATCH_SESSION_EVENT_TYPE_CELL_OUTPUT = 3; + */ + CELL_OUTPUT = 3, + + /** + * @generated from enum value: WATCH_SESSION_EVENT_TYPE_CELL_COMPLETED = 4; + */ + CELL_COMPLETED = 4, + + /** + * @generated from enum value: WATCH_SESSION_EVENT_TYPE_EVENT_ADDED = 5; + */ + EVENT_ADDED = 5, +} +// Retrieve enum metadata with: proto3.getEnumType(WatchSessionEventType) +proto3.util.setEnumType(WatchSessionEventType, "agentcompose.v1.WatchSessionEventType", [ + { no: 0, name: "WATCH_SESSION_EVENT_TYPE_UNSPECIFIED" }, + { no: 1, name: "WATCH_SESSION_EVENT_TYPE_SESSION_UPDATED" }, + { no: 2, name: "WATCH_SESSION_EVENT_TYPE_CELL_STARTED" }, + { no: 3, name: "WATCH_SESSION_EVENT_TYPE_CELL_OUTPUT" }, + { no: 4, name: "WATCH_SESSION_EVENT_TYPE_CELL_COMPLETED" }, + { no: 5, name: "WATCH_SESSION_EVENT_TYPE_EVENT_ADDED" }, +]); + +/** + * @generated from enum agentcompose.v1.LoaderTriggerKind + */ +export enum LoaderTriggerKind { + /** + * @generated from enum value: LOADER_TRIGGER_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: LOADER_TRIGGER_KIND_INTERVAL = 1; + */ + INTERVAL = 1, + + /** + * @generated from enum value: LOADER_TRIGGER_KIND_EVENT = 2; + */ + EVENT = 2, + + /** + * @generated from enum value: LOADER_TRIGGER_KIND_TIMEOUT = 3; + */ + TIMEOUT = 3, + + /** + * @generated from enum value: LOADER_TRIGGER_KIND_CRON = 4; + */ + CRON = 4, +} +// Retrieve enum metadata with: proto3.getEnumType(LoaderTriggerKind) +proto3.util.setEnumType(LoaderTriggerKind, "agentcompose.v1.LoaderTriggerKind", [ + { no: 0, name: "LOADER_TRIGGER_KIND_UNSPECIFIED" }, + { no: 1, name: "LOADER_TRIGGER_KIND_INTERVAL" }, + { no: 2, name: "LOADER_TRIGGER_KIND_EVENT" }, + { no: 3, name: "LOADER_TRIGGER_KIND_TIMEOUT" }, + { no: 4, name: "LOADER_TRIGGER_KIND_CRON" }, +]); + +/** + * @generated from enum agentcompose.v1.AgentAvailabilityStatus + */ +export enum AgentAvailabilityStatus { + /** + * @generated from enum value: AGENT_AVAILABILITY_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AGENT_AVAILABILITY_STATUS_AVAILABLE = 1; + */ + AVAILABLE = 1, + + /** + * @generated from enum value: AGENT_AVAILABILITY_STATUS_UNAVAILABLE = 2; + */ + UNAVAILABLE = 2, + + /** + * @generated from enum value: AGENT_AVAILABILITY_STATUS_VALIDATION_FAILED = 3; + */ + VALIDATION_FAILED = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(AgentAvailabilityStatus) +proto3.util.setEnumType(AgentAvailabilityStatus, "agentcompose.v1.AgentAvailabilityStatus", [ + { no: 0, name: "AGENT_AVAILABILITY_STATUS_UNSPECIFIED" }, + { no: 1, name: "AGENT_AVAILABILITY_STATUS_AVAILABLE" }, + { no: 2, name: "AGENT_AVAILABILITY_STATUS_UNAVAILABLE" }, + { no: 3, name: "AGENT_AVAILABILITY_STATUS_VALIDATION_FAILED" }, +]); + +/** + * @generated from enum agentcompose.v1.AgentHealthStatus + */ +export enum AgentHealthStatus { + /** + * @generated from enum value: AGENT_HEALTH_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AGENT_HEALTH_STATUS_HEALTHY = 1; + */ + HEALTHY = 1, + + /** + * @generated from enum value: AGENT_HEALTH_STATUS_AT_RISK = 2; + */ + AT_RISK = 2, +} +// Retrieve enum metadata with: proto3.getEnumType(AgentHealthStatus) +proto3.util.setEnumType(AgentHealthStatus, "agentcompose.v1.AgentHealthStatus", [ + { no: 0, name: "AGENT_HEALTH_STATUS_UNSPECIFIED" }, + { no: 1, name: "AGENT_HEALTH_STATUS_HEALTHY" }, + { no: 2, name: "AGENT_HEALTH_STATUS_AT_RISK" }, +]); + +/** + * @generated from enum agentcompose.v1.AgentCurrentRunStatus + */ +export enum AgentCurrentRunStatus { + /** + * @generated from enum value: AGENT_CURRENT_RUN_STATUS_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AGENT_CURRENT_RUN_STATUS_IDLE = 1; + */ + IDLE = 1, + + /** + * @generated from enum value: AGENT_CURRENT_RUN_STATUS_HAS_RUNNING_SESSION = 2; + */ + HAS_RUNNING_SESSION = 2, +} +// Retrieve enum metadata with: proto3.getEnumType(AgentCurrentRunStatus) +proto3.util.setEnumType(AgentCurrentRunStatus, "agentcompose.v1.AgentCurrentRunStatus", [ + { no: 0, name: "AGENT_CURRENT_RUN_STATUS_UNSPECIFIED" }, + { no: 1, name: "AGENT_CURRENT_RUN_STATUS_IDLE" }, + { no: 2, name: "AGENT_CURRENT_RUN_STATUS_HAS_RUNNING_SESSION" }, +]); + +/** + * @generated from enum agentcompose.v1.AgentWorkFilesSource + */ +export enum AgentWorkFilesSource { + /** + * @generated from enum value: AGENT_WORK_FILES_SOURCE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: AGENT_WORK_FILES_SOURCE_EMPTY = 1; + */ + EMPTY = 1, + + /** + * @generated from enum value: AGENT_WORK_FILES_SOURCE_FILE_WORKSPACE = 2; + */ + FILE_WORKSPACE = 2, + + /** + * @generated from enum value: AGENT_WORK_FILES_SOURCE_GIT_WORKSPACE = 3; + */ + GIT_WORKSPACE = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(AgentWorkFilesSource) +proto3.util.setEnumType(AgentWorkFilesSource, "agentcompose.v1.AgentWorkFilesSource", [ + { no: 0, name: "AGENT_WORK_FILES_SOURCE_UNSPECIFIED" }, + { no: 1, name: "AGENT_WORK_FILES_SOURCE_EMPTY" }, + { no: 2, name: "AGENT_WORK_FILES_SOURCE_FILE_WORKSPACE" }, + { no: 3, name: "AGENT_WORK_FILES_SOURCE_GIT_WORKSPACE" }, +]); + +/** + * @generated from message agentcompose.v1.SessionIDRequest + */ +export class SessionIDRequest extends Message { + /** + * @generated from field: string session_id = 1; + */ + sessionId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionIDRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionIDRequest { + return new SessionIDRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionIDRequest { + return new SessionIDRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionIDRequest { + return new SessionIDRequest().fromJsonString(jsonString, options); + } + + static equals(a: SessionIDRequest | PlainMessage | undefined, b: SessionIDRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionIDRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionTag + */ +export class SessionTag extends Message { + /** + * @generated from field: string name = 1; + */ + name = ""; + + /** + * @generated from field: string value = 2; + */ + value = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionTag"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "value", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionTag { + return new SessionTag().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionTag { + return new SessionTag().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionTag { + return new SessionTag().fromJsonString(jsonString, options); + } + + static equals(a: SessionTag | PlainMessage | undefined, b: SessionTag | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionTag, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionEnvVar + */ +export class SessionEnvVar extends Message { + /** + * @generated from field: string name = 1; + */ + name = ""; + + /** + * @generated from field: string value = 2; + */ + value = ""; + + /** + * @generated from field: bool secret = 3; + */ + secret = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionEnvVar"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "value", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "secret", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionEnvVar { + return new SessionEnvVar().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionEnvVar { + return new SessionEnvVar().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionEnvVar { + return new SessionEnvVar().fromJsonString(jsonString, options); + } + + static equals(a: SessionEnvVar | PlainMessage | undefined, b: SessionEnvVar | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionEnvVar, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CreateSessionRequest + */ +export class CreateSessionRequest extends Message { + /** + * @generated from field: string title = 1; + */ + title = ""; + + /** + * @generated from field: string base_workspace = 2; + */ + baseWorkspace = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionTag tags = 5; + */ + tags: SessionTag[] = []; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 6; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: string workspace_id = 7; + */ + workspaceId = ""; + + /** + * @generated from field: string guest_image = 8; + */ + guestImage = ""; + + /** + * @generated from field: string driver = 9; + */ + driver = ""; + + /** + * @generated from field: repeated string capset_ids = 10; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CreateSessionRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "base_workspace", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "tags", kind: "message", T: SessionTag, repeated: true }, + { no: 6, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 7, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateSessionRequest { + return new CreateSessionRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateSessionRequest { + return new CreateSessionRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateSessionRequest { + return new CreateSessionRequest().fromJsonString(jsonString, options); + } + + static equals(a: CreateSessionRequest | PlainMessage | undefined, b: CreateSessionRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateSessionRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.WorkspaceConfig + */ +export class WorkspaceConfig extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string type = 3; + */ + type = ""; + + /** + * @generated from field: string config_json = 4; + */ + configJson = ""; + + /** + * @generated from field: string comment = 5; + */ + comment = ""; + + /** + * @generated from field: string created_at = 6; + */ + createdAt = ""; + + /** + * @generated from field: string updated_at = 7; + */ + updatedAt = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.WorkspaceConfig"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "comment", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "updated_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): WorkspaceConfig { + return new WorkspaceConfig().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): WorkspaceConfig { + return new WorkspaceConfig().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): WorkspaceConfig { + return new WorkspaceConfig().fromJsonString(jsonString, options); + } + + static equals(a: WorkspaceConfig | PlainMessage | undefined, b: WorkspaceConfig | PlainMessage | undefined): boolean { + return proto3.util.equals(WorkspaceConfig, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionWorkspaceSnapshot + */ +export class SessionWorkspaceSnapshot extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string type = 3; + */ + type = ""; + + /** + * @generated from field: string config_json = 4; + */ + configJson = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionWorkspaceSnapshot"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionWorkspaceSnapshot { + return new SessionWorkspaceSnapshot().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionWorkspaceSnapshot { + return new SessionWorkspaceSnapshot().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionWorkspaceSnapshot { + return new SessionWorkspaceSnapshot().fromJsonString(jsonString, options); + } + + static equals(a: SessionWorkspaceSnapshot | PlainMessage | undefined, b: SessionWorkspaceSnapshot | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionWorkspaceSnapshot, a, b); + } +} + +/** + * @generated from message agentcompose.v1.WorkspaceConfigIDRequest + */ +export class WorkspaceConfigIDRequest extends Message { + /** + * @generated from field: string workspace_id = 1; + */ + workspaceId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.WorkspaceConfigIDRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): WorkspaceConfigIDRequest { + return new WorkspaceConfigIDRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): WorkspaceConfigIDRequest { + return new WorkspaceConfigIDRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): WorkspaceConfigIDRequest { + return new WorkspaceConfigIDRequest().fromJsonString(jsonString, options); + } + + static equals(a: WorkspaceConfigIDRequest | PlainMessage | undefined, b: WorkspaceConfigIDRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(WorkspaceConfigIDRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CreateWorkspaceConfigRequest + */ +export class CreateWorkspaceConfigRequest extends Message { + /** + * @generated from field: string name = 1; + */ + name = ""; + + /** + * @generated from field: string type = 2; + */ + type = ""; + + /** + * @generated from field: string config_json = 3; + */ + configJson = ""; + + /** + * @generated from field: string comment = 4; + */ + comment = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CreateWorkspaceConfigRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "comment", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateWorkspaceConfigRequest { + return new CreateWorkspaceConfigRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateWorkspaceConfigRequest { + return new CreateWorkspaceConfigRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateWorkspaceConfigRequest { + return new CreateWorkspaceConfigRequest().fromJsonString(jsonString, options); + } + + static equals(a: CreateWorkspaceConfigRequest | PlainMessage | undefined, b: CreateWorkspaceConfigRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateWorkspaceConfigRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.UpdateWorkspaceConfigRequest + */ +export class UpdateWorkspaceConfigRequest extends Message { + /** + * @generated from field: string workspace_id = 1; + */ + workspaceId = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string type = 3; + */ + type = ""; + + /** + * @generated from field: string config_json = 4; + */ + configJson = ""; + + /** + * @generated from field: string comment = 5; + */ + comment = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.UpdateWorkspaceConfigRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "comment", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateWorkspaceConfigRequest { + return new UpdateWorkspaceConfigRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateWorkspaceConfigRequest { + return new UpdateWorkspaceConfigRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateWorkspaceConfigRequest { + return new UpdateWorkspaceConfigRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateWorkspaceConfigRequest | PlainMessage | undefined, b: UpdateWorkspaceConfigRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateWorkspaceConfigRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.WorkspaceConfigResponse + */ +export class WorkspaceConfigResponse extends Message { + /** + * @generated from field: agentcompose.v1.WorkspaceConfig workspace = 1; + */ + workspace?: WorkspaceConfig; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.WorkspaceConfigResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "workspace", kind: "message", T: WorkspaceConfig }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): WorkspaceConfigResponse { + return new WorkspaceConfigResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): WorkspaceConfigResponse { + return new WorkspaceConfigResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): WorkspaceConfigResponse { + return new WorkspaceConfigResponse().fromJsonString(jsonString, options); + } + + static equals(a: WorkspaceConfigResponse | PlainMessage | undefined, b: WorkspaceConfigResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(WorkspaceConfigResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListWorkspaceConfigsResponse + */ +export class ListWorkspaceConfigsResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.WorkspaceConfig workspaces = 1; + */ + workspaces: WorkspaceConfig[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListWorkspaceConfigsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "workspaces", kind: "message", T: WorkspaceConfig, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListWorkspaceConfigsResponse { + return new ListWorkspaceConfigsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListWorkspaceConfigsResponse { + return new ListWorkspaceConfigsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListWorkspaceConfigsResponse { + return new ListWorkspaceConfigsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListWorkspaceConfigsResponse | PlainMessage | undefined, b: ListWorkspaceConfigsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListWorkspaceConfigsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionSummary + */ +export class SessionSummary extends Message { + /** + * @generated from field: string session_id = 1; + */ + sessionId = ""; + + /** + * @generated from field: string title = 2; + */ + title = ""; + + /** + * @generated from field: string driver = 3; + */ + driver = ""; + + /** + * @generated from field: string vm_status = 4; + */ + vmStatus = ""; + + /** + * @generated from field: string workspace_path = 5; + */ + workspacePath = ""; + + /** + * @generated from field: string proxy_path = 6; + */ + proxyPath = ""; + + /** + * @generated from field: string created_at = 7; + */ + createdAt = ""; + + /** + * @generated from field: string updated_at = 8; + */ + updatedAt = ""; + + /** + * @generated from field: uint32 cell_count = 9; + */ + cellCount = 0; + + /** + * @generated from field: uint32 event_count = 10; + */ + eventCount = 0; + + /** + * @generated from field: repeated agentcompose.v1.SessionTag tags = 11; + */ + tags: SessionTag[] = []; + + /** + * @generated from field: string guest_image = 12; + */ + guestImage = ""; + + /** + * @generated from field: string trigger_source = 13; + */ + triggerSource = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionSummary"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "vm_status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "workspace_path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "proxy_path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "updated_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "cell_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 10, name: "event_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 11, name: "tags", kind: "message", T: SessionTag, repeated: true }, + { no: 12, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 13, name: "trigger_source", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionSummary { + return new SessionSummary().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionSummary { + return new SessionSummary().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionSummary { + return new SessionSummary().fromJsonString(jsonString, options); + } + + static equals(a: SessionSummary | PlainMessage | undefined, b: SessionSummary | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionSummary, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListSessionsRequest + */ +export class ListSessionsRequest extends Message { + /** + * @generated from field: string session_type = 1; + */ + sessionType = ""; + + /** + * @generated from field: string trigger_source_query = 2; + */ + triggerSourceQuery = ""; + + /** + * @generated from field: string title_query = 3; + */ + titleQuery = ""; + + /** + * @generated from field: string workspace_query = 4; + */ + workspaceQuery = ""; + + /** + * @generated from field: string driver = 5; + */ + driver = ""; + + /** + * @generated from field: string vm_status = 6; + */ + vmStatus = ""; + + /** + * @generated from field: string created_from = 7; + */ + createdFrom = ""; + + /** + * @generated from field: string created_to = 8; + */ + createdTo = ""; + + /** + * @generated from field: string updated_from = 9; + */ + updatedFrom = ""; + + /** + * @generated from field: string updated_to = 10; + */ + updatedTo = ""; + + /** + * @generated from field: uint32 offset = 11; + */ + offset = 0; + + /** + * @generated from field: uint32 limit = 12; + */ + limit = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListSessionsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "trigger_source_query", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "title_query", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "workspace_query", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "vm_status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "created_from", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "created_to", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "updated_from", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "updated_to", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "offset", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 12, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListSessionsRequest { + return new ListSessionsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListSessionsRequest { + return new ListSessionsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListSessionsRequest { + return new ListSessionsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListSessionsRequest | PlainMessage | undefined, b: ListSessionsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListSessionsRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionDetail + */ +export class SessionDetail extends Message { + /** + * @generated from field: agentcompose.v1.SessionSummary summary = 1; + */ + summary?: SessionSummary; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 4; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: string workspace_id = 5; + */ + workspaceId = ""; + + /** + * @generated from field: agentcompose.v1.SessionWorkspaceSnapshot workspace = 6; + */ + workspace?: SessionWorkspaceSnapshot; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionDetail"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "summary", kind: "message", T: SessionSummary }, + { no: 4, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 5, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "workspace", kind: "message", T: SessionWorkspaceSnapshot }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionDetail { + return new SessionDetail().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionDetail { + return new SessionDetail().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionDetail { + return new SessionDetail().fromJsonString(jsonString, options); + } + + static equals(a: SessionDetail | PlainMessage | undefined, b: SessionDetail | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionDetail, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionResponse + */ +export class SessionResponse extends Message { + /** + * @generated from field: agentcompose.v1.SessionDetail session = 1; + */ + session?: SessionDetail; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session", kind: "message", T: SessionDetail }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionResponse { + return new SessionResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionResponse { + return new SessionResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionResponse { + return new SessionResponse().fromJsonString(jsonString, options); + } + + static equals(a: SessionResponse | PlainMessage | undefined, b: SessionResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListSessionsResponse + */ +export class ListSessionsResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.SessionSummary sessions = 1; + */ + sessions: SessionSummary[] = []; + + /** + * @generated from field: uint32 total_count = 2; + */ + totalCount = 0; + + /** + * @generated from field: bool has_more = 3; + */ + hasMore = false; + + /** + * @generated from field: uint32 next_offset = 4; + */ + nextOffset = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListSessionsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "sessions", kind: "message", T: SessionSummary, repeated: true }, + { no: 2, name: "total_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "has_more", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 4, name: "next_offset", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListSessionsResponse { + return new ListSessionsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListSessionsResponse { + return new ListSessionsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListSessionsResponse { + return new ListSessionsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListSessionsResponse | PlainMessage | undefined, b: ListSessionsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListSessionsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionProxyResponse + */ +export class SessionProxyResponse extends Message { + /** + * @generated from field: string session_id = 1; + */ + sessionId = ""; + + /** + * @generated from field: string proxy_path = 2; + */ + proxyPath = ""; + + /** + * @generated from field: string notebook_url = 3; + */ + notebookUrl = ""; + + /** + * @generated from field: string driver = 4; + */ + driver = ""; + + /** + * @generated from field: string vm_status = 5; + */ + vmStatus = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionProxyResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "proxy_path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "notebook_url", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "vm_status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionProxyResponse { + return new SessionProxyResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionProxyResponse { + return new SessionProxyResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionProxyResponse { + return new SessionProxyResponse().fromJsonString(jsonString, options); + } + + static equals(a: SessionProxyResponse | PlainMessage | undefined, b: SessionProxyResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionProxyResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.WatchSessionResponse + */ +export class WatchSessionResponse extends Message { + /** + * @generated from field: agentcompose.v1.WatchSessionEventType event_type = 1; + */ + eventType = WatchSessionEventType.UNSPECIFIED; + + /** + * @generated from field: agentcompose.v1.SessionSummary session = 2; + */ + session?: SessionSummary; + + /** + * @generated from field: agentcompose.v1.NotebookCell cell = 3; + */ + cell?: NotebookCell; + + /** + * @generated from field: agentcompose.v1.SessionEvent event = 4; + */ + event?: SessionEvent; + + /** + * @generated from field: string cell_id = 5; + */ + cellId = ""; + + /** + * @generated from field: string chunk = 6; + */ + chunk = ""; + + /** + * @generated from field: bool is_stderr = 7; + */ + isStderr = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.WatchSessionResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "event_type", kind: "enum", T: proto3.getEnumType(WatchSessionEventType) }, + { no: 2, name: "session", kind: "message", T: SessionSummary }, + { no: 3, name: "cell", kind: "message", T: NotebookCell }, + { no: 4, name: "event", kind: "message", T: SessionEvent }, + { no: 5, name: "cell_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "chunk", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "is_stderr", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): WatchSessionResponse { + return new WatchSessionResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): WatchSessionResponse { + return new WatchSessionResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): WatchSessionResponse { + return new WatchSessionResponse().fromJsonString(jsonString, options); + } + + static equals(a: WatchSessionResponse | PlainMessage | undefined, b: WatchSessionResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(WatchSessionResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.NotebookCell + */ +export class NotebookCell extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string source = 2; + */ + source = ""; + + /** + * @generated from field: string stdout = 3 [deprecated = true]; + * @deprecated + */ + stdout = ""; + + /** + * @generated from field: string stderr = 4 [deprecated = true]; + * @deprecated + */ + stderr = ""; + + /** + * @generated from field: bool success = 5; + */ + success = false; + + /** + * @generated from field: string created_at = 6; + */ + createdAt = ""; + + /** + * @generated from field: agentcompose.v1.CellType type = 7; + */ + type = CellType.UNSPECIFIED; + + /** + * @generated from field: int32 exit_code = 8; + */ + exitCode = 0; + + /** + * @generated from field: string output = 9; + */ + output = ""; + + /** + * @generated from field: string agent = 10; + */ + agent = ""; + + /** + * @generated from field: string agent_session_id = 11; + */ + agentSessionId = ""; + + /** + * @generated from field: string stop_reason = 12; + */ + stopReason = ""; + + /** + * @generated from field: bool running = 13; + */ + running = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.NotebookCell"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "source", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "stdout", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "stderr", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 6, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "type", kind: "enum", T: proto3.getEnumType(CellType) }, + { no: 8, name: "exit_code", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 9, name: "output", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "agent", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "agent_session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 12, name: "stop_reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 13, name: "running", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): NotebookCell { + return new NotebookCell().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): NotebookCell { + return new NotebookCell().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): NotebookCell { + return new NotebookCell().fromJsonString(jsonString, options); + } + + static equals(a: NotebookCell | PlainMessage | undefined, b: NotebookCell | PlainMessage | undefined): boolean { + return proto3.util.equals(NotebookCell, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ExecuteCellRequest + */ +export class ExecuteCellRequest extends Message { + /** + * @generated from field: string session_id = 1; + */ + sessionId = ""; + + /** + * @generated from field: string source = 2; + */ + source = ""; + + /** + * @generated from field: agentcompose.v1.CellType type = 3; + */ + type = CellType.UNSPECIFIED; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ExecuteCellRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "source", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "type", kind: "enum", T: proto3.getEnumType(CellType) }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ExecuteCellRequest { + return new ExecuteCellRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ExecuteCellRequest { + return new ExecuteCellRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ExecuteCellRequest { + return new ExecuteCellRequest().fromJsonString(jsonString, options); + } + + static equals(a: ExecuteCellRequest | PlainMessage | undefined, b: ExecuteCellRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ExecuteCellRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ExecuteCellResponse + */ +export class ExecuteCellResponse extends Message { + /** + * @generated from field: agentcompose.v1.SessionSummary session = 1; + */ + session?: SessionSummary; + + /** + * @generated from field: agentcompose.v1.NotebookCell cell = 2; + */ + cell?: NotebookCell; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ExecuteCellResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session", kind: "message", T: SessionSummary }, + { no: 2, name: "cell", kind: "message", T: NotebookCell }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ExecuteCellResponse { + return new ExecuteCellResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ExecuteCellResponse { + return new ExecuteCellResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ExecuteCellResponse { + return new ExecuteCellResponse().fromJsonString(jsonString, options); + } + + static equals(a: ExecuteCellResponse | PlainMessage | undefined, b: ExecuteCellResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ExecuteCellResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ExecuteCellStreamResponse + */ +export class ExecuteCellStreamResponse extends Message { + /** + * @generated from field: agentcompose.v1.ExecuteCellStreamEventType event_type = 1; + */ + eventType = ExecuteCellStreamEventType.UNSPECIFIED; + + /** + * @generated from field: agentcompose.v1.SessionSummary session = 2; + */ + session?: SessionSummary; + + /** + * @generated from field: agentcompose.v1.NotebookCell cell = 3; + */ + cell?: NotebookCell; + + /** + * @generated from field: string cell_id = 4; + */ + cellId = ""; + + /** + * @generated from field: string chunk = 5; + */ + chunk = ""; + + /** + * @generated from field: bool is_stderr = 6; + */ + isStderr = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ExecuteCellStreamResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "event_type", kind: "enum", T: proto3.getEnumType(ExecuteCellStreamEventType) }, + { no: 2, name: "session", kind: "message", T: SessionSummary }, + { no: 3, name: "cell", kind: "message", T: NotebookCell }, + { no: 4, name: "cell_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "chunk", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "is_stderr", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ExecuteCellStreamResponse { + return new ExecuteCellStreamResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ExecuteCellStreamResponse { + return new ExecuteCellStreamResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ExecuteCellStreamResponse { + return new ExecuteCellStreamResponse().fromJsonString(jsonString, options); + } + + static equals(a: ExecuteCellStreamResponse | PlainMessage | undefined, b: ExecuteCellStreamResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ExecuteCellStreamResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListCellsResponse + */ +export class ListCellsResponse extends Message { + /** + * @generated from field: string session_id = 1; + */ + sessionId = ""; + + /** + * @generated from field: repeated agentcompose.v1.NotebookCell cells = 2; + */ + cells: NotebookCell[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListCellsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "cells", kind: "message", T: NotebookCell, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListCellsResponse { + return new ListCellsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListCellsResponse { + return new ListCellsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListCellsResponse { + return new ListCellsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListCellsResponse | PlainMessage | undefined, b: ListCellsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListCellsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SessionEvent + */ +export class SessionEvent extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string type = 2; + */ + type = ""; + + /** + * @generated from field: string level = 3; + */ + level = ""; + + /** + * @generated from field: string message = 4; + */ + message = ""; + + /** + * @generated from field: string created_at = 5; + */ + createdAt = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SessionEvent"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "level", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SessionEvent { + return new SessionEvent().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SessionEvent { + return new SessionEvent().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SessionEvent { + return new SessionEvent().fromJsonString(jsonString, options); + } + + static equals(a: SessionEvent | PlainMessage | undefined, b: SessionEvent | PlainMessage | undefined): boolean { + return proto3.util.equals(SessionEvent, a, b); + } +} + +/** + * @generated from message agentcompose.v1.AgentRun + */ +export class AgentRun extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string agent = 2; + */ + agent = ""; + + /** + * @generated from field: string message = 3; + */ + message = ""; + + /** + * @generated from field: string output = 4; + */ + output = ""; + + /** + * @generated from field: int32 exit_code = 5; + */ + exitCode = 0; + + /** + * @generated from field: bool success = 6; + */ + success = false; + + /** + * @generated from field: string created_at = 7; + */ + createdAt = ""; + + /** + * @generated from field: string agent_session_id = 8; + */ + agentSessionId = ""; + + /** + * @generated from field: string stop_reason = 9; + */ + stopReason = ""; + + /** + * @generated from field: bool running = 10; + */ + running = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.AgentRun"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "agent", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "output", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "exit_code", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 6, name: "success", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 7, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "agent_session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "stop_reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "running", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): AgentRun { + return new AgentRun().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): AgentRun { + return new AgentRun().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): AgentRun { + return new AgentRun().fromJsonString(jsonString, options); + } + + static equals(a: AgentRun | PlainMessage | undefined, b: AgentRun | PlainMessage | undefined): boolean { + return proto3.util.equals(AgentRun, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SendAgentMessageRequest + */ +export class SendAgentMessageRequest extends Message { + /** + * @generated from field: string session_id = 1; + */ + sessionId = ""; + + /** + * @generated from field: string message = 2; + */ + message = ""; + + /** + * @generated from field: string agent = 3; + */ + agent = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SendAgentMessageRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "agent", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SendAgentMessageRequest { + return new SendAgentMessageRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SendAgentMessageRequest { + return new SendAgentMessageRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SendAgentMessageRequest { + return new SendAgentMessageRequest().fromJsonString(jsonString, options); + } + + static equals(a: SendAgentMessageRequest | PlainMessage | undefined, b: SendAgentMessageRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SendAgentMessageRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SendAgentMessageResponse + */ +export class SendAgentMessageResponse extends Message { + /** + * @generated from field: agentcompose.v1.SessionEvent user_event = 1; + */ + userEvent?: SessionEvent; + + /** + * @generated from field: agentcompose.v1.SessionEvent assistant_event = 2; + */ + assistantEvent?: SessionEvent; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SendAgentMessageResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "user_event", kind: "message", T: SessionEvent }, + { no: 2, name: "assistant_event", kind: "message", T: SessionEvent }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SendAgentMessageResponse { + return new SendAgentMessageResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SendAgentMessageResponse { + return new SendAgentMessageResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SendAgentMessageResponse { + return new SendAgentMessageResponse().fromJsonString(jsonString, options); + } + + static equals(a: SendAgentMessageResponse | PlainMessage | undefined, b: SendAgentMessageResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(SendAgentMessageResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SendAgentMessageStreamResponse + */ +export class SendAgentMessageStreamResponse extends Message { + /** + * @generated from field: agentcompose.v1.SendAgentMessageStreamEventType event_type = 1; + */ + eventType = SendAgentMessageStreamEventType.UNSPECIFIED; + + /** + * @generated from field: agentcompose.v1.SessionSummary session = 2; + */ + session?: SessionSummary; + + /** + * @generated from field: agentcompose.v1.AgentRun run = 3; + */ + run?: AgentRun; + + /** + * @generated from field: string run_id = 4; + */ + runId = ""; + + /** + * @generated from field: string chunk = 5; + */ + chunk = ""; + + /** + * @generated from field: bool is_stderr = 6; + */ + isStderr = false; + + /** + * @generated from field: agentcompose.v1.SessionEvent user_event = 7; + */ + userEvent?: SessionEvent; + + /** + * @generated from field: agentcompose.v1.SessionEvent assistant_event = 8; + */ + assistantEvent?: SessionEvent; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SendAgentMessageStreamResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "event_type", kind: "enum", T: proto3.getEnumType(SendAgentMessageStreamEventType) }, + { no: 2, name: "session", kind: "message", T: SessionSummary }, + { no: 3, name: "run", kind: "message", T: AgentRun }, + { no: 4, name: "run_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "chunk", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "is_stderr", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 7, name: "user_event", kind: "message", T: SessionEvent }, + { no: 8, name: "assistant_event", kind: "message", T: SessionEvent }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SendAgentMessageStreamResponse { + return new SendAgentMessageStreamResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SendAgentMessageStreamResponse { + return new SendAgentMessageStreamResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SendAgentMessageStreamResponse { + return new SendAgentMessageStreamResponse().fromJsonString(jsonString, options); + } + + static equals(a: SendAgentMessageStreamResponse | PlainMessage | undefined, b: SendAgentMessageStreamResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(SendAgentMessageStreamResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.GenerateLLMRequest + */ +export class GenerateLLMRequest extends Message { + /** + * @generated from field: string prompt = 1; + */ + prompt = ""; + + /** + * @generated from field: string model = 2; + */ + model = ""; + + /** + * @generated from field: string output_schema = 3; + */ + outputSchema = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.GenerateLLMRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "prompt", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "output_schema", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GenerateLLMRequest { + return new GenerateLLMRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GenerateLLMRequest { + return new GenerateLLMRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GenerateLLMRequest { + return new GenerateLLMRequest().fromJsonString(jsonString, options); + } + + static equals(a: GenerateLLMRequest | PlainMessage | undefined, b: GenerateLLMRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GenerateLLMRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.GenerateLLMResponse + */ +export class GenerateLLMResponse extends Message { + /** + * @generated from field: string text = 1; + */ + text = ""; + + /** + * @generated from field: string model = 2; + */ + model = ""; + + /** + * @generated from field: string response_id = 3; + */ + responseId = ""; + + /** + * @generated from field: string finish_reason = 4; + */ + finishReason = ""; + + /** + * @generated from field: string json = 5; + */ + json = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.GenerateLLMResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "text", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "response_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "finish_reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GenerateLLMResponse { + return new GenerateLLMResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GenerateLLMResponse { + return new GenerateLLMResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GenerateLLMResponse { + return new GenerateLLMResponse().fromJsonString(jsonString, options); + } + + static equals(a: GenerateLLMResponse | PlainMessage | undefined, b: GenerateLLMResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GenerateLLMResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListSessionEventsResponse + */ +export class ListSessionEventsResponse extends Message { + /** + * @generated from field: string session_id = 1; + */ + sessionId = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionEvent events = 2; + */ + events: SessionEvent[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListSessionEventsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "events", kind: "message", T: SessionEvent, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListSessionEventsResponse { + return new ListSessionEventsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListSessionEventsResponse { + return new ListSessionEventsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListSessionEventsResponse { + return new ListSessionEventsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListSessionEventsResponse | PlainMessage | undefined, b: ListSessionEventsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListSessionEventsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.AgentDefinition + */ +export class AgentDefinition extends Message { + /** + * @generated from field: string agent_id = 1; + */ + agentId = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string description = 3; + */ + description = ""; + + /** + * @generated from field: bool enabled = 4; + */ + enabled = false; + + /** + * @generated from field: string provider = 5; + */ + provider = ""; + + /** + * @generated from field: string model = 6; + */ + model = ""; + + /** + * @generated from field: string system_prompt = 7; + */ + systemPrompt = ""; + + /** + * @generated from field: string runtime_image_id = 8; + */ + runtimeImageId = ""; + + /** + * @generated from field: string driver = 9; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 10; + */ + guestImage = ""; + + /** + * @generated from field: agentcompose.v1.AgentWorkFiles work_files = 11; + */ + workFiles?: AgentWorkFiles; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 12; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: string config_json = 13; + */ + configJson = ""; + + /** + * @generated from field: agentcompose.v1.AgentAvailabilityStatus availability_status = 14; + */ + availabilityStatus = AgentAvailabilityStatus.UNSPECIFIED; + + /** + * @generated from field: agentcompose.v1.AgentHealthStatus health_status = 15; + */ + healthStatus = AgentHealthStatus.UNSPECIFIED; + + /** + * @generated from field: agentcompose.v1.AgentCurrentRunSummary current_run_summary = 16; + */ + currentRunSummary?: AgentCurrentRunSummary; + + /** + * @generated from field: agentcompose.v1.AgentLatestRunSummary latest_run_summary = 17; + */ + latestRunSummary?: AgentLatestRunSummary; + + /** + * @generated from field: string created_at = 18; + */ + createdAt = ""; + + /** + * @generated from field: string updated_at = 19; + */ + updatedAt = ""; + + /** + * @generated from field: string deleted_at = 20; + */ + deletedAt = ""; + + /** + * @generated from field: repeated string capset_ids = 21; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.AgentDefinition"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 5, name: "provider", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "system_prompt", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "runtime_image_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "work_files", kind: "message", T: AgentWorkFiles }, + { no: 12, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 13, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 14, name: "availability_status", kind: "enum", T: proto3.getEnumType(AgentAvailabilityStatus) }, + { no: 15, name: "health_status", kind: "enum", T: proto3.getEnumType(AgentHealthStatus) }, + { no: 16, name: "current_run_summary", kind: "message", T: AgentCurrentRunSummary }, + { no: 17, name: "latest_run_summary", kind: "message", T: AgentLatestRunSummary }, + { no: 18, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 19, name: "updated_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 20, name: "deleted_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 21, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): AgentDefinition { + return new AgentDefinition().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): AgentDefinition { + return new AgentDefinition().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): AgentDefinition { + return new AgentDefinition().fromJsonString(jsonString, options); + } + + static equals(a: AgentDefinition | PlainMessage | undefined, b: AgentDefinition | PlainMessage | undefined): boolean { + return proto3.util.equals(AgentDefinition, a, b); + } +} + +/** + * @generated from message agentcompose.v1.AgentWorkFiles + */ +export class AgentWorkFiles extends Message { + /** + * @generated from field: agentcompose.v1.AgentWorkFilesSource source = 1; + */ + source = AgentWorkFilesSource.UNSPECIFIED; + + /** + * @generated from field: string workspace_id = 2; + */ + workspaceId = ""; + + /** + * @generated from field: string workspace_name = 3; + */ + workspaceName = ""; + + /** + * @generated from field: string workspace_type = 4; + */ + workspaceType = ""; + + /** + * @generated from field: string summary = 5; + */ + summary = ""; + + /** + * @generated from field: string config_json = 6; + */ + configJson = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.AgentWorkFiles"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "source", kind: "enum", T: proto3.getEnumType(AgentWorkFilesSource) }, + { no: 2, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "workspace_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "workspace_type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "summary", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): AgentWorkFiles { + return new AgentWorkFiles().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): AgentWorkFiles { + return new AgentWorkFiles().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): AgentWorkFiles { + return new AgentWorkFiles().fromJsonString(jsonString, options); + } + + static equals(a: AgentWorkFiles | PlainMessage | undefined, b: AgentWorkFiles | PlainMessage | undefined): boolean { + return proto3.util.equals(AgentWorkFiles, a, b); + } +} + +/** + * @generated from message agentcompose.v1.AgentCurrentRunSummary + */ +export class AgentCurrentRunSummary extends Message { + /** + * @generated from field: agentcompose.v1.AgentCurrentRunStatus status = 1; + */ + status = AgentCurrentRunStatus.UNSPECIFIED; + + /** + * @generated from field: string text = 2; + */ + text = ""; + + /** + * @generated from field: uint32 running_session_count = 3; + */ + runningSessionCount = 0; + + /** + * @generated from field: uint32 running_loader_run_count = 4; + */ + runningLoaderRunCount = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.AgentCurrentRunSummary"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "status", kind: "enum", T: proto3.getEnumType(AgentCurrentRunStatus) }, + { no: 2, name: "text", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "running_session_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 4, name: "running_loader_run_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): AgentCurrentRunSummary { + return new AgentCurrentRunSummary().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): AgentCurrentRunSummary { + return new AgentCurrentRunSummary().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): AgentCurrentRunSummary { + return new AgentCurrentRunSummary().fromJsonString(jsonString, options); + } + + static equals(a: AgentCurrentRunSummary | PlainMessage | undefined, b: AgentCurrentRunSummary | PlainMessage | undefined): boolean { + return proto3.util.equals(AgentCurrentRunSummary, a, b); + } +} + +/** + * @generated from message agentcompose.v1.AgentLatestRunSummary + */ +export class AgentLatestRunSummary extends Message { + /** + * @generated from field: string run_type = 1; + */ + runType = ""; + + /** + * @generated from field: string status = 2; + */ + status = ""; + + /** + * @generated from field: string run_id = 3; + */ + runId = ""; + + /** + * @generated from field: string title = 4; + */ + title = ""; + + /** + * @generated from field: string at = 5; + */ + at = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.AgentLatestRunSummary"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "run_type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "run_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): AgentLatestRunSummary { + return new AgentLatestRunSummary().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): AgentLatestRunSummary { + return new AgentLatestRunSummary().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): AgentLatestRunSummary { + return new AgentLatestRunSummary().fromJsonString(jsonString, options); + } + + static equals(a: AgentLatestRunSummary | PlainMessage | undefined, b: AgentLatestRunSummary | PlainMessage | undefined): boolean { + return proto3.util.equals(AgentLatestRunSummary, a, b); + } +} + +/** + * @generated from message agentcompose.v1.AgentDefinitionIDRequest + */ +export class AgentDefinitionIDRequest extends Message { + /** + * @generated from field: string agent_id = 1; + */ + agentId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.AgentDefinitionIDRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): AgentDefinitionIDRequest { + return new AgentDefinitionIDRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): AgentDefinitionIDRequest { + return new AgentDefinitionIDRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): AgentDefinitionIDRequest { + return new AgentDefinitionIDRequest().fromJsonString(jsonString, options); + } + + static equals(a: AgentDefinitionIDRequest | PlainMessage | undefined, b: AgentDefinitionIDRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(AgentDefinitionIDRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.AgentDefinitionResponse + */ +export class AgentDefinitionResponse extends Message { + /** + * @generated from field: agentcompose.v1.AgentDefinition agent = 1; + */ + agent?: AgentDefinition; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.AgentDefinitionResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agent", kind: "message", T: AgentDefinition }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): AgentDefinitionResponse { + return new AgentDefinitionResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): AgentDefinitionResponse { + return new AgentDefinitionResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): AgentDefinitionResponse { + return new AgentDefinitionResponse().fromJsonString(jsonString, options); + } + + static equals(a: AgentDefinitionResponse | PlainMessage | undefined, b: AgentDefinitionResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(AgentDefinitionResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListAgentDefinitionsRequest + */ +export class ListAgentDefinitionsRequest extends Message { + /** + * @generated from field: string query = 1; + */ + query = ""; + + /** + * @generated from field: bool include_disabled = 2; + */ + includeDisabled = false; + + /** + * @generated from field: uint32 offset = 3; + */ + offset = 0; + + /** + * @generated from field: uint32 limit = 4; + */ + limit = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListAgentDefinitionsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "query", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "include_disabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 3, name: "offset", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 4, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListAgentDefinitionsRequest { + return new ListAgentDefinitionsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListAgentDefinitionsRequest { + return new ListAgentDefinitionsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListAgentDefinitionsRequest { + return new ListAgentDefinitionsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListAgentDefinitionsRequest | PlainMessage | undefined, b: ListAgentDefinitionsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListAgentDefinitionsRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListAgentDefinitionsResponse + */ +export class ListAgentDefinitionsResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.AgentDefinition agents = 1; + */ + agents: AgentDefinition[] = []; + + /** + * @generated from field: uint32 total_count = 2; + */ + totalCount = 0; + + /** + * @generated from field: bool has_more = 3; + */ + hasMore = false; + + /** + * @generated from field: uint32 next_offset = 4; + */ + nextOffset = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListAgentDefinitionsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agents", kind: "message", T: AgentDefinition, repeated: true }, + { no: 2, name: "total_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "has_more", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 4, name: "next_offset", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListAgentDefinitionsResponse { + return new ListAgentDefinitionsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListAgentDefinitionsResponse { + return new ListAgentDefinitionsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListAgentDefinitionsResponse { + return new ListAgentDefinitionsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListAgentDefinitionsResponse | PlainMessage | undefined, b: ListAgentDefinitionsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListAgentDefinitionsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CreateAgentDefinitionRequest + */ +export class CreateAgentDefinitionRequest extends Message { + /** + * @generated from field: string name = 1; + */ + name = ""; + + /** + * @generated from field: string description = 2; + */ + description = ""; + + /** + * @generated from field: bool enabled = 3; + */ + enabled = false; + + /** + * @generated from field: string provider = 4; + */ + provider = ""; + + /** + * @generated from field: string model = 5; + */ + model = ""; + + /** + * @generated from field: string system_prompt = 6; + */ + systemPrompt = ""; + + /** + * @generated from field: string runtime_image_id = 7; + */ + runtimeImageId = ""; + + /** + * @generated from field: string driver = 8; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 9; + */ + guestImage = ""; + + /** + * @generated from field: string workspace_id = 10; + */ + workspaceId = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 11; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: string config_json = 12; + */ + configJson = ""; + + /** + * @generated from field: repeated string capset_ids = 13; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CreateAgentDefinitionRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 4, name: "provider", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "system_prompt", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "runtime_image_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 12, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 13, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateAgentDefinitionRequest { + return new CreateAgentDefinitionRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateAgentDefinitionRequest { + return new CreateAgentDefinitionRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateAgentDefinitionRequest { + return new CreateAgentDefinitionRequest().fromJsonString(jsonString, options); + } + + static equals(a: CreateAgentDefinitionRequest | PlainMessage | undefined, b: CreateAgentDefinitionRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateAgentDefinitionRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.UpdateAgentDefinitionRequest + */ +export class UpdateAgentDefinitionRequest extends Message { + /** + * @generated from field: string agent_id = 1; + */ + agentId = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string description = 3; + */ + description = ""; + + /** + * @generated from field: bool enabled = 4; + */ + enabled = false; + + /** + * @generated from field: string provider = 5; + */ + provider = ""; + + /** + * @generated from field: string model = 6; + */ + model = ""; + + /** + * @generated from field: string system_prompt = 7; + */ + systemPrompt = ""; + + /** + * @generated from field: string runtime_image_id = 8; + */ + runtimeImageId = ""; + + /** + * @generated from field: string driver = 9; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 10; + */ + guestImage = ""; + + /** + * @generated from field: string workspace_id = 11; + */ + workspaceId = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 12; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: string config_json = 13; + */ + configJson = ""; + + /** + * @generated from field: repeated string capset_ids = 14; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.UpdateAgentDefinitionRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 5, name: "provider", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "system_prompt", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "runtime_image_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 12, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 13, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 14, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateAgentDefinitionRequest { + return new UpdateAgentDefinitionRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateAgentDefinitionRequest { + return new UpdateAgentDefinitionRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateAgentDefinitionRequest { + return new UpdateAgentDefinitionRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateAgentDefinitionRequest | PlainMessage | undefined, b: UpdateAgentDefinitionRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateAgentDefinitionRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SetAgentDefinitionEnabledRequest + */ +export class SetAgentDefinitionEnabledRequest extends Message { + /** + * @generated from field: string agent_id = 1; + */ + agentId = ""; + + /** + * @generated from field: bool enabled = 2; + */ + enabled = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SetAgentDefinitionEnabledRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SetAgentDefinitionEnabledRequest { + return new SetAgentDefinitionEnabledRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SetAgentDefinitionEnabledRequest { + return new SetAgentDefinitionEnabledRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SetAgentDefinitionEnabledRequest { + return new SetAgentDefinitionEnabledRequest().fromJsonString(jsonString, options); + } + + static equals(a: SetAgentDefinitionEnabledRequest | PlainMessage | undefined, b: SetAgentDefinitionEnabledRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SetAgentDefinitionEnabledRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ValidateAgentDefinitionRequest + */ +export class ValidateAgentDefinitionRequest extends Message { + /** + * @generated from field: string agent_id = 1; + */ + agentId = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string provider = 3; + */ + provider = ""; + + /** + * @generated from field: string model = 4; + */ + model = ""; + + /** + * @generated from field: string system_prompt = 5; + */ + systemPrompt = ""; + + /** + * @generated from field: string runtime_image_id = 6; + */ + runtimeImageId = ""; + + /** + * @generated from field: string driver = 7; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 8; + */ + guestImage = ""; + + /** + * @generated from field: string workspace_id = 9; + */ + workspaceId = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 10; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: string config_json = 11; + */ + configJson = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ValidateAgentDefinitionRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "provider", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "system_prompt", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "runtime_image_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 11, name: "config_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ValidateAgentDefinitionRequest { + return new ValidateAgentDefinitionRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ValidateAgentDefinitionRequest { + return new ValidateAgentDefinitionRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ValidateAgentDefinitionRequest { + return new ValidateAgentDefinitionRequest().fromJsonString(jsonString, options); + } + + static equals(a: ValidateAgentDefinitionRequest | PlainMessage | undefined, b: ValidateAgentDefinitionRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ValidateAgentDefinitionRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ValidateAgentDefinitionResponse + */ +export class ValidateAgentDefinitionResponse extends Message { + /** + * @generated from field: agentcompose.v1.AgentAvailabilityStatus availability_status = 1; + */ + availabilityStatus = AgentAvailabilityStatus.UNSPECIFIED; + + /** + * @generated from field: agentcompose.v1.AgentHealthStatus health_status = 2; + */ + healthStatus = AgentHealthStatus.UNSPECIFIED; + + /** + * @generated from field: repeated string warnings = 3; + */ + warnings: string[] = []; + + /** + * @generated from field: repeated string errors = 4; + */ + errors: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ValidateAgentDefinitionResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "availability_status", kind: "enum", T: proto3.getEnumType(AgentAvailabilityStatus) }, + { no: 2, name: "health_status", kind: "enum", T: proto3.getEnumType(AgentHealthStatus) }, + { no: 3, name: "warnings", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + { no: 4, name: "errors", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ValidateAgentDefinitionResponse { + return new ValidateAgentDefinitionResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ValidateAgentDefinitionResponse { + return new ValidateAgentDefinitionResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ValidateAgentDefinitionResponse { + return new ValidateAgentDefinitionResponse().fromJsonString(jsonString, options); + } + + static equals(a: ValidateAgentDefinitionResponse | PlainMessage | undefined, b: ValidateAgentDefinitionResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ValidateAgentDefinitionResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CreateAgentSessionRequest + */ +export class CreateAgentSessionRequest extends Message { + /** + * @generated from field: string agent_id = 1; + */ + agentId = ""; + + /** + * @generated from field: string title = 2; + */ + title = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 3; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: string workspace_id = 4; + */ + workspaceId = ""; + + /** + * @generated from field: string driver = 5; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 6; + */ + guestImage = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CreateAgentSessionRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "title", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 4, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateAgentSessionRequest { + return new CreateAgentSessionRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateAgentSessionRequest { + return new CreateAgentSessionRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateAgentSessionRequest { + return new CreateAgentSessionRequest().fromJsonString(jsonString, options); + } + + static equals(a: CreateAgentSessionRequest | PlainMessage | undefined, b: CreateAgentSessionRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateAgentSessionRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.UpdateGlobalEnvConfigRequest + */ +export class UpdateGlobalEnvConfigRequest extends Message { + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 1; + */ + envItems: SessionEnvVar[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.UpdateGlobalEnvConfigRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateGlobalEnvConfigRequest { + return new UpdateGlobalEnvConfigRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateGlobalEnvConfigRequest { + return new UpdateGlobalEnvConfigRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateGlobalEnvConfigRequest { + return new UpdateGlobalEnvConfigRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateGlobalEnvConfigRequest | PlainMessage | undefined, b: UpdateGlobalEnvConfigRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateGlobalEnvConfigRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.GlobalEnvConfigResponse + */ +export class GlobalEnvConfigResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 1; + */ + envItems: SessionEnvVar[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.GlobalEnvConfigResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GlobalEnvConfigResponse { + return new GlobalEnvConfigResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GlobalEnvConfigResponse { + return new GlobalEnvConfigResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GlobalEnvConfigResponse { + return new GlobalEnvConfigResponse().fromJsonString(jsonString, options); + } + + static equals(a: GlobalEnvConfigResponse | PlainMessage | undefined, b: GlobalEnvConfigResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GlobalEnvConfigResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CapabilityGatewayConfig + */ +export class CapabilityGatewayConfig extends Message { + /** + * @generated from field: string addr = 1; + */ + addr = ""; + + /** + * @generated from field: bool token_set = 2; + */ + tokenSet = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CapabilityGatewayConfig"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "addr", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "token_set", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CapabilityGatewayConfig { + return new CapabilityGatewayConfig().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CapabilityGatewayConfig { + return new CapabilityGatewayConfig().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CapabilityGatewayConfig { + return new CapabilityGatewayConfig().fromJsonString(jsonString, options); + } + + static equals(a: CapabilityGatewayConfig | PlainMessage | undefined, b: CapabilityGatewayConfig | PlainMessage | undefined): boolean { + return proto3.util.equals(CapabilityGatewayConfig, a, b); + } +} + +/** + * @generated from message agentcompose.v1.UpdateCapabilityGatewayConfigRequest + */ +export class UpdateCapabilityGatewayConfigRequest extends Message { + /** + * @generated from field: string addr = 1; + */ + addr = ""; + + /** + * @generated from field: string token = 2; + */ + token = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.UpdateCapabilityGatewayConfigRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "addr", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "token", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateCapabilityGatewayConfigRequest { + return new UpdateCapabilityGatewayConfigRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateCapabilityGatewayConfigRequest { + return new UpdateCapabilityGatewayConfigRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateCapabilityGatewayConfigRequest { + return new UpdateCapabilityGatewayConfigRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateCapabilityGatewayConfigRequest | PlainMessage | undefined, b: UpdateCapabilityGatewayConfigRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateCapabilityGatewayConfigRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderIDRequest + */ +export class LoaderIDRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderIDRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderIDRequest { + return new LoaderIDRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderIDRequest { + return new LoaderIDRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderIDRequest { + return new LoaderIDRequest().fromJsonString(jsonString, options); + } + + static equals(a: LoaderIDRequest | PlainMessage | undefined, b: LoaderIDRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderIDRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderRunIDRequest + */ +export class LoaderRunIDRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: string run_id = 2; + */ + runId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderRunIDRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "run_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderRunIDRequest { + return new LoaderRunIDRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderRunIDRequest { + return new LoaderRunIDRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderRunIDRequest { + return new LoaderRunIDRequest().fromJsonString(jsonString, options); + } + + static equals(a: LoaderRunIDRequest | PlainMessage | undefined, b: LoaderRunIDRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderRunIDRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderTrigger + */ +export class LoaderTrigger extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: string trigger_id = 2; + */ + triggerId = ""; + + /** + * @generated from field: agentcompose.v1.LoaderTriggerKind kind = 3; + */ + kind = LoaderTriggerKind.UNSPECIFIED; + + /** + * @generated from field: string topic = 4; + */ + topic = ""; + + /** + * @generated from field: int64 interval_ms = 5; + */ + intervalMs = protoInt64.zero; + + /** + * @generated from field: bool enabled = 6; + */ + enabled = false; + + /** + * @generated from field: bool auto_id = 7; + */ + autoId = false; + + /** + * @generated from field: string spec_json = 8; + */ + specJson = ""; + + /** + * @generated from field: string next_fire_at = 9; + */ + nextFireAt = ""; + + /** + * @generated from field: string last_fired_at = 10; + */ + lastFiredAt = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderTrigger"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "trigger_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "kind", kind: "enum", T: proto3.getEnumType(LoaderTriggerKind) }, + { no: 4, name: "topic", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "interval_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 6, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 7, name: "auto_id", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 8, name: "spec_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "next_fire_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "last_fired_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderTrigger { + return new LoaderTrigger().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderTrigger { + return new LoaderTrigger().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderTrigger { + return new LoaderTrigger().fromJsonString(jsonString, options); + } + + static equals(a: LoaderTrigger | PlainMessage | undefined, b: LoaderTrigger | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderTrigger, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderSummary + */ +export class LoaderSummary extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string description = 3; + */ + description = ""; + + /** + * @generated from field: bool enabled = 4; + */ + enabled = false; + + /** + * @generated from field: string runtime = 5; + */ + runtime = ""; + + /** + * @generated from field: string workspace_id = 6; + */ + workspaceId = ""; + + /** + * @generated from field: string driver = 7; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 8; + */ + guestImage = ""; + + /** + * @generated from field: string default_agent = 9; + */ + defaultAgent = ""; + + /** + * @generated from field: string session_policy = 10; + */ + sessionPolicy = ""; + + /** + * @generated from field: string concurrency_policy = 11; + */ + concurrencyPolicy = ""; + + /** + * @generated from field: string created_at = 12; + */ + createdAt = ""; + + /** + * @generated from field: string updated_at = 13; + */ + updatedAt = ""; + + /** + * @generated from field: string last_error = 14; + */ + lastError = ""; + + /** + * @generated from field: uint32 trigger_count = 15; + */ + triggerCount = 0; + + /** + * @generated from field: uint32 run_count = 16; + */ + runCount = 0; + + /** + * @generated from field: uint32 event_count = 17; + */ + eventCount = 0; + + /** + * @generated from field: string latest_run_at = 18; + */ + latestRunAt = ""; + + /** + * @generated from field: string agent_id = 19; + */ + agentId = ""; + + /** + * @generated from field: repeated string capset_ids = 20; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderSummary"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 5, name: "runtime", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "default_agent", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "session_policy", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "concurrency_policy", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 12, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 13, name: "updated_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 14, name: "last_error", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 15, name: "trigger_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 16, name: "run_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 17, name: "event_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 18, name: "latest_run_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 19, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 20, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderSummary { + return new LoaderSummary().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderSummary { + return new LoaderSummary().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderSummary { + return new LoaderSummary().fromJsonString(jsonString, options); + } + + static equals(a: LoaderSummary | PlainMessage | undefined, b: LoaderSummary | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderSummary, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderDetail + */ +export class LoaderDetail extends Message { + /** + * @generated from field: agentcompose.v1.LoaderSummary summary = 1; + */ + summary?: LoaderSummary; + + /** + * @generated from field: string script = 2; + */ + script = ""; + + /** + * @generated from field: repeated agentcompose.v1.LoaderTrigger triggers = 3; + */ + triggers: LoaderTrigger[] = []; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 4; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: repeated string capset_ids = 5; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderDetail"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "summary", kind: "message", T: LoaderSummary }, + { no: 2, name: "script", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "triggers", kind: "message", T: LoaderTrigger, repeated: true }, + { no: 4, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 5, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderDetail { + return new LoaderDetail().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderDetail { + return new LoaderDetail().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderDetail { + return new LoaderDetail().fromJsonString(jsonString, options); + } + + static equals(a: LoaderDetail | PlainMessage | undefined, b: LoaderDetail | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderDetail, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderResponse + */ +export class LoaderResponse extends Message { + /** + * @generated from field: agentcompose.v1.LoaderDetail loader = 1; + */ + loader?: LoaderDetail; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader", kind: "message", T: LoaderDetail }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderResponse { + return new LoaderResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderResponse { + return new LoaderResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderResponse { + return new LoaderResponse().fromJsonString(jsonString, options); + } + + static equals(a: LoaderResponse | PlainMessage | undefined, b: LoaderResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListLoadersResponse + */ +export class ListLoadersResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.LoaderSummary loaders = 1; + */ + loaders: LoaderSummary[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListLoadersResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loaders", kind: "message", T: LoaderSummary, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListLoadersResponse { + return new ListLoadersResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListLoadersResponse { + return new ListLoadersResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListLoadersResponse { + return new ListLoadersResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListLoadersResponse | PlainMessage | undefined, b: ListLoadersResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListLoadersResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CreateLoaderRequest + */ +export class CreateLoaderRequest extends Message { + /** + * @generated from field: string name = 1; + */ + name = ""; + + /** + * @generated from field: string description = 2; + */ + description = ""; + + /** + * @generated from field: string runtime = 3; + */ + runtime = ""; + + /** + * @generated from field: string script = 4; + */ + script = ""; + + /** + * @generated from field: string workspace_id = 5; + */ + workspaceId = ""; + + /** + * @generated from field: string driver = 6; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 7; + */ + guestImage = ""; + + /** + * @generated from field: string default_agent = 8; + */ + defaultAgent = ""; + + /** + * @generated from field: string session_policy = 9; + */ + sessionPolicy = ""; + + /** + * @generated from field: string concurrency_policy = 10; + */ + concurrencyPolicy = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 11; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: bool enabled = 12; + */ + enabled = false; + + /** + * @generated from field: string agent_id = 13; + */ + agentId = ""; + + /** + * @generated from field: repeated string capset_ids = 14; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CreateLoaderRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "runtime", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "script", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "default_agent", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "session_policy", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "concurrency_policy", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 12, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 13, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 14, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CreateLoaderRequest { + return new CreateLoaderRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CreateLoaderRequest { + return new CreateLoaderRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CreateLoaderRequest { + return new CreateLoaderRequest().fromJsonString(jsonString, options); + } + + static equals(a: CreateLoaderRequest | PlainMessage | undefined, b: CreateLoaderRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(CreateLoaderRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.UpdateLoaderRequest + */ +export class UpdateLoaderRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string description = 3; + */ + description = ""; + + /** + * @generated from field: string runtime = 4; + */ + runtime = ""; + + /** + * @generated from field: string script = 5; + */ + script = ""; + + /** + * @generated from field: string workspace_id = 6; + */ + workspaceId = ""; + + /** + * @generated from field: string driver = 7; + */ + driver = ""; + + /** + * @generated from field: string guest_image = 8; + */ + guestImage = ""; + + /** + * @generated from field: string default_agent = 9; + */ + defaultAgent = ""; + + /** + * @generated from field: string session_policy = 10; + */ + sessionPolicy = ""; + + /** + * @generated from field: string concurrency_policy = 11; + */ + concurrencyPolicy = ""; + + /** + * @generated from field: repeated agentcompose.v1.SessionEnvVar env_items = 12; + */ + envItems: SessionEnvVar[] = []; + + /** + * @generated from field: bool enabled = 13; + */ + enabled = false; + + /** + * @generated from field: string agent_id = 14; + */ + agentId = ""; + + /** + * @generated from field: repeated string capset_ids = 15; + */ + capsetIds: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.UpdateLoaderRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "runtime", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "script", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "workspace_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "guest_image", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "default_agent", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "session_policy", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "concurrency_policy", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 12, name: "env_items", kind: "message", T: SessionEnvVar, repeated: true }, + { no: 13, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 14, name: "agent_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 15, name: "capset_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateLoaderRequest { + return new UpdateLoaderRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateLoaderRequest { + return new UpdateLoaderRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateLoaderRequest { + return new UpdateLoaderRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateLoaderRequest | PlainMessage | undefined, b: UpdateLoaderRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateLoaderRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.GetCapabilityStatusRequest + */ +export class GetCapabilityStatusRequest extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.GetCapabilityStatusRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetCapabilityStatusRequest { + return new GetCapabilityStatusRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetCapabilityStatusRequest { + return new GetCapabilityStatusRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetCapabilityStatusRequest { + return new GetCapabilityStatusRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetCapabilityStatusRequest | PlainMessage | undefined, b: GetCapabilityStatusRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetCapabilityStatusRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CapabilityStatusResponse + */ +export class CapabilityStatusResponse extends Message { + /** + * @generated from field: bool configured = 1; + */ + configured = false; + + /** + * @generated from field: bool ok = 2; + */ + ok = false; + + /** + * @generated from field: string status = 3; + */ + status = ""; + + /** + * @generated from field: uint32 service_count = 4; + */ + serviceCount = 0; + + /** + * @generated from field: string error = 5; + */ + error = ""; + + /** + * @generated from field: bool runtime_configured = 6; + */ + runtimeConfigured = false; + + /** + * @generated from field: bool proxy_listen_configured = 7; + */ + proxyListenConfigured = false; + + /** + * @generated from field: bool proxy_target_configured = 8; + */ + proxyTargetConfigured = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CapabilityStatusResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "configured", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 2, name: "ok", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 3, name: "status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "service_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 5, name: "error", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "runtime_configured", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 7, name: "proxy_listen_configured", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 8, name: "proxy_target_configured", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CapabilityStatusResponse { + return new CapabilityStatusResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CapabilityStatusResponse { + return new CapabilityStatusResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CapabilityStatusResponse { + return new CapabilityStatusResponse().fromJsonString(jsonString, options); + } + + static equals(a: CapabilityStatusResponse | PlainMessage | undefined, b: CapabilityStatusResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(CapabilityStatusResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListCapabilitySetsRequest + */ +export class ListCapabilitySetsRequest extends Message { + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListCapabilitySetsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListCapabilitySetsRequest { + return new ListCapabilitySetsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListCapabilitySetsRequest { + return new ListCapabilitySetsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListCapabilitySetsRequest { + return new ListCapabilitySetsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListCapabilitySetsRequest | PlainMessage | undefined, b: ListCapabilitySetsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListCapabilitySetsRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CapabilitySet + */ +export class CapabilitySet extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string description = 3; + */ + description = ""; + + /** + * @generated from field: bool enabled = 4; + */ + enabled = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CapabilitySet"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CapabilitySet { + return new CapabilitySet().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CapabilitySet { + return new CapabilitySet().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CapabilitySet { + return new CapabilitySet().fromJsonString(jsonString, options); + } + + static equals(a: CapabilitySet | PlainMessage | undefined, b: CapabilitySet | PlainMessage | undefined): boolean { + return proto3.util.equals(CapabilitySet, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListCapabilitySetsResponse + */ +export class ListCapabilitySetsResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.CapabilitySet capsets = 1; + */ + capsets: CapabilitySet[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListCapabilitySetsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "capsets", kind: "message", T: CapabilitySet, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListCapabilitySetsResponse { + return new ListCapabilitySetsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListCapabilitySetsResponse { + return new ListCapabilitySetsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListCapabilitySetsResponse { + return new ListCapabilitySetsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListCapabilitySetsResponse | PlainMessage | undefined, b: ListCapabilitySetsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListCapabilitySetsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.GetCapabilityCatalogRequest + */ +export class GetCapabilityCatalogRequest extends Message { + /** + * @generated from field: string capset_id = 1; + */ + capsetId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.GetCapabilityCatalogRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "capset_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetCapabilityCatalogRequest { + return new GetCapabilityCatalogRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetCapabilityCatalogRequest { + return new GetCapabilityCatalogRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetCapabilityCatalogRequest { + return new GetCapabilityCatalogRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetCapabilityCatalogRequest | PlainMessage | undefined, b: GetCapabilityCatalogRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetCapabilityCatalogRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CapabilityEndpoint + */ +export class CapabilityEndpoint extends Message { + /** + * @generated from field: string protocol = 1; + */ + protocol = ""; + + /** + * @generated from field: string endpoint = 2; + */ + endpoint = ""; + + /** + * @generated from field: string method_path = 3; + */ + methodPath = ""; + + /** + * @generated from field: map metadata = 4; + */ + metadata: { [key: string]: string } = {}; + + /** + * @generated from field: string tool_name = 5; + */ + toolName = ""; + + /** + * @generated from field: string procedure = 6; + */ + procedure = ""; + + /** + * @generated from field: string http_method = 7; + */ + httpMethod = ""; + + /** + * @generated from field: repeated string content_types = 8; + */ + contentTypes: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CapabilityEndpoint"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "protocol", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "endpoint", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "method_path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "metadata", kind: "map", K: 9 /* ScalarType.STRING */, V: {kind: "scalar", T: 9 /* ScalarType.STRING */} }, + { no: 5, name: "tool_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "procedure", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "http_method", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "content_types", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CapabilityEndpoint { + return new CapabilityEndpoint().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CapabilityEndpoint { + return new CapabilityEndpoint().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CapabilityEndpoint { + return new CapabilityEndpoint().fromJsonString(jsonString, options); + } + + static equals(a: CapabilityEndpoint | PlainMessage | undefined, b: CapabilityEndpoint | PlainMessage | undefined): boolean { + return proto3.util.equals(CapabilityEndpoint, a, b); + } +} + +/** + * @generated from message agentcompose.v1.CapabilityMethod + */ +export class CapabilityMethod extends Message { + /** + * @generated from field: string service_id = 1; + */ + serviceId = ""; + + /** + * @generated from field: string instance_id = 2; + */ + instanceId = ""; + + /** + * @generated from field: string runtime_mode = 3; + */ + runtimeMode = ""; + + /** + * @generated from field: string method_full_name = 4; + */ + methodFullName = ""; + + /** + * @generated from field: string request_message_full_name = 5; + */ + requestMessageFullName = ""; + + /** + * @generated from field: string response_message_full_name = 6; + */ + responseMessageFullName = ""; + + /** + * @generated from field: string backend_instance_status = 7; + */ + backendInstanceStatus = ""; + + /** + * @generated from field: repeated agentcompose.v1.CapabilityEndpoint endpoints = 8; + */ + endpoints: CapabilityEndpoint[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.CapabilityMethod"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "service_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "instance_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "runtime_mode", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "method_full_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "request_message_full_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "response_message_full_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "backend_instance_status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "endpoints", kind: "message", T: CapabilityEndpoint, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): CapabilityMethod { + return new CapabilityMethod().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): CapabilityMethod { + return new CapabilityMethod().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): CapabilityMethod { + return new CapabilityMethod().fromJsonString(jsonString, options); + } + + static equals(a: CapabilityMethod | PlainMessage | undefined, b: CapabilityMethod | PlainMessage | undefined): boolean { + return proto3.util.equals(CapabilityMethod, a, b); + } +} + +/** + * @generated from message agentcompose.v1.GetCapabilityCatalogResponse + */ +export class GetCapabilityCatalogResponse extends Message { + /** + * @generated from field: string capset_id = 1; + */ + capsetId = ""; + + /** + * @generated from field: string name = 2; + */ + name = ""; + + /** + * @generated from field: string description = 3; + */ + description = ""; + + /** + * @generated from field: repeated agentcompose.v1.CapabilityMethod methods = 4; + */ + methods: CapabilityMethod[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.GetCapabilityCatalogResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "capset_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "methods", kind: "message", T: CapabilityMethod, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetCapabilityCatalogResponse { + return new GetCapabilityCatalogResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetCapabilityCatalogResponse { + return new GetCapabilityCatalogResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetCapabilityCatalogResponse { + return new GetCapabilityCatalogResponse().fromJsonString(jsonString, options); + } + + static equals(a: GetCapabilityCatalogResponse | PlainMessage | undefined, b: GetCapabilityCatalogResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetCapabilityCatalogResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ValidateLoaderRequest + */ +export class ValidateLoaderRequest extends Message { + /** + * @generated from field: string script = 1; + */ + script = ""; + + /** + * @generated from field: string runtime = 2; + */ + runtime = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ValidateLoaderRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "script", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "runtime", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ValidateLoaderRequest { + return new ValidateLoaderRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ValidateLoaderRequest { + return new ValidateLoaderRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ValidateLoaderRequest { + return new ValidateLoaderRequest().fromJsonString(jsonString, options); + } + + static equals(a: ValidateLoaderRequest | PlainMessage | undefined, b: ValidateLoaderRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ValidateLoaderRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ValidateLoaderResponse + */ +export class ValidateLoaderResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.LoaderTrigger triggers = 1; + */ + triggers: LoaderTrigger[] = []; + + /** + * @generated from field: repeated string warnings = 2; + */ + warnings: string[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ValidateLoaderResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "triggers", kind: "message", T: LoaderTrigger, repeated: true }, + { no: 2, name: "warnings", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ValidateLoaderResponse { + return new ValidateLoaderResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ValidateLoaderResponse { + return new ValidateLoaderResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ValidateLoaderResponse { + return new ValidateLoaderResponse().fromJsonString(jsonString, options); + } + + static equals(a: ValidateLoaderResponse | PlainMessage | undefined, b: ValidateLoaderResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ValidateLoaderResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SetLoaderEnabledRequest + */ +export class SetLoaderEnabledRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: bool enabled = 2; + */ + enabled = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SetLoaderEnabledRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SetLoaderEnabledRequest { + return new SetLoaderEnabledRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SetLoaderEnabledRequest { + return new SetLoaderEnabledRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SetLoaderEnabledRequest { + return new SetLoaderEnabledRequest().fromJsonString(jsonString, options); + } + + static equals(a: SetLoaderEnabledRequest | PlainMessage | undefined, b: SetLoaderEnabledRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SetLoaderEnabledRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.SetLoaderTriggerEnabledRequest + */ +export class SetLoaderTriggerEnabledRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: string trigger_id = 2; + */ + triggerId = ""; + + /** + * @generated from field: bool enabled = 3; + */ + enabled = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.SetLoaderTriggerEnabledRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "trigger_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): SetLoaderTriggerEnabledRequest { + return new SetLoaderTriggerEnabledRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SetLoaderTriggerEnabledRequest { + return new SetLoaderTriggerEnabledRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SetLoaderTriggerEnabledRequest { + return new SetLoaderTriggerEnabledRequest().fromJsonString(jsonString, options); + } + + static equals(a: SetLoaderTriggerEnabledRequest | PlainMessage | undefined, b: SetLoaderTriggerEnabledRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SetLoaderTriggerEnabledRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderRunSummary + */ +export class LoaderRunSummary extends Message { + /** + * @generated from field: string run_id = 1; + */ + runId = ""; + + /** + * @generated from field: string loader_id = 2; + */ + loaderId = ""; + + /** + * @generated from field: string trigger_id = 3; + */ + triggerId = ""; + + /** + * @generated from field: agentcompose.v1.LoaderTriggerKind trigger_kind = 4; + */ + triggerKind = LoaderTriggerKind.UNSPECIFIED; + + /** + * @generated from field: string trigger_source = 5; + */ + triggerSource = ""; + + /** + * @generated from field: string status = 6; + */ + status = ""; + + /** + * @generated from field: string started_at = 7; + */ + startedAt = ""; + + /** + * @generated from field: string completed_at = 8; + */ + completedAt = ""; + + /** + * @generated from field: int64 duration_ms = 9; + */ + durationMs = protoInt64.zero; + + /** + * @generated from field: string error = 10; + */ + error = ""; + + /** + * @generated from field: string result_json = 11; + */ + resultJson = ""; + + /** + * @generated from field: string payload_json = 12; + */ + payloadJson = ""; + + /** + * @generated from field: string source_script_sha256 = 13; + */ + sourceScriptSha256 = ""; + + /** + * @generated from field: string artifacts_dir = 14; + */ + artifactsDir = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderRunSummary"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "run_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "trigger_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "trigger_kind", kind: "enum", T: proto3.getEnumType(LoaderTriggerKind) }, + { no: 5, name: "trigger_source", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "started_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "completed_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "duration_ms", kind: "scalar", T: 3 /* ScalarType.INT64 */ }, + { no: 10, name: "error", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "result_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 12, name: "payload_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 13, name: "source_script_sha256", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 14, name: "artifacts_dir", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderRunSummary { + return new LoaderRunSummary().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderRunSummary { + return new LoaderRunSummary().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderRunSummary { + return new LoaderRunSummary().fromJsonString(jsonString, options); + } + + static equals(a: LoaderRunSummary | PlainMessage | undefined, b: LoaderRunSummary | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderRunSummary, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderRunDetail + */ +export class LoaderRunDetail extends Message { + /** + * @generated from field: agentcompose.v1.LoaderRunSummary summary = 1; + */ + summary?: LoaderRunSummary; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderRunDetail"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "summary", kind: "message", T: LoaderRunSummary }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderRunDetail { + return new LoaderRunDetail().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderRunDetail { + return new LoaderRunDetail().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderRunDetail { + return new LoaderRunDetail().fromJsonString(jsonString, options); + } + + static equals(a: LoaderRunDetail | PlainMessage | undefined, b: LoaderRunDetail | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderRunDetail, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderRunResponse + */ +export class LoaderRunResponse extends Message { + /** + * @generated from field: agentcompose.v1.LoaderRunDetail run = 1; + */ + run?: LoaderRunDetail; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderRunResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "run", kind: "message", T: LoaderRunDetail }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderRunResponse { + return new LoaderRunResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderRunResponse { + return new LoaderRunResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderRunResponse { + return new LoaderRunResponse().fromJsonString(jsonString, options); + } + + static equals(a: LoaderRunResponse | PlainMessage | undefined, b: LoaderRunResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderRunResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListLoaderRunsRequest + */ +export class ListLoaderRunsRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: uint32 limit = 2; + */ + limit = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListLoaderRunsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListLoaderRunsRequest { + return new ListLoaderRunsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListLoaderRunsRequest { + return new ListLoaderRunsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListLoaderRunsRequest { + return new ListLoaderRunsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListLoaderRunsRequest | PlainMessage | undefined, b: ListLoaderRunsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListLoaderRunsRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListLoaderRunsResponse + */ +export class ListLoaderRunsResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.LoaderRunSummary runs = 1; + */ + runs: LoaderRunSummary[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListLoaderRunsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "runs", kind: "message", T: LoaderRunSummary, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListLoaderRunsResponse { + return new ListLoaderRunsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListLoaderRunsResponse { + return new ListLoaderRunsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListLoaderRunsResponse { + return new ListLoaderRunsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListLoaderRunsResponse | PlainMessage | undefined, b: ListLoaderRunsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListLoaderRunsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.RunLoaderNowRequest + */ +export class RunLoaderNowRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: string trigger_id = 2; + */ + triggerId = ""; + + /** + * @generated from field: string payload_json = 3; + */ + payloadJson = ""; + + /** + * @generated from field: string timeout = 4; + */ + timeout = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.RunLoaderNowRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "trigger_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "payload_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "timeout", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RunLoaderNowRequest { + return new RunLoaderNowRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RunLoaderNowRequest { + return new RunLoaderNowRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RunLoaderNowRequest { + return new RunLoaderNowRequest().fromJsonString(jsonString, options); + } + + static equals(a: RunLoaderNowRequest | PlainMessage | undefined, b: RunLoaderNowRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(RunLoaderNowRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.LoaderEvent + */ +export class LoaderEvent extends Message { + /** + * @generated from field: string id = 1; + */ + id = ""; + + /** + * @generated from field: string loader_id = 2; + */ + loaderId = ""; + + /** + * @generated from field: string run_id = 3; + */ + runId = ""; + + /** + * @generated from field: string trigger_id = 4; + */ + triggerId = ""; + + /** + * @generated from field: string type = 5; + */ + type = ""; + + /** + * @generated from field: string level = 6; + */ + level = ""; + + /** + * @generated from field: string message = 7; + */ + message = ""; + + /** + * @generated from field: string payload_json = 8; + */ + payloadJson = ""; + + /** + * @generated from field: string linked_session_id = 9; + */ + linkedSessionId = ""; + + /** + * @generated from field: string linked_cell_id = 10; + */ + linkedCellId = ""; + + /** + * @generated from field: string linked_agent_session_id = 11; + */ + linkedAgentSessionId = ""; + + /** + * @generated from field: string created_at = 12; + */ + createdAt = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.LoaderEvent"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "run_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "trigger_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 5, name: "type", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 6, name: "level", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 7, name: "message", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 8, name: "payload_json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 9, name: "linked_session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 10, name: "linked_cell_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "linked_agent_session_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 12, name: "created_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): LoaderEvent { + return new LoaderEvent().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): LoaderEvent { + return new LoaderEvent().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): LoaderEvent { + return new LoaderEvent().fromJsonString(jsonString, options); + } + + static equals(a: LoaderEvent | PlainMessage | undefined, b: LoaderEvent | PlainMessage | undefined): boolean { + return proto3.util.equals(LoaderEvent, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListLoaderEventsRequest + */ +export class ListLoaderEventsRequest extends Message { + /** + * @generated from field: string loader_id = 1; + */ + loaderId = ""; + + /** + * @generated from field: uint32 limit = 2; + */ + limit = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListLoaderEventsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "loader_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "limit", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListLoaderEventsRequest { + return new ListLoaderEventsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListLoaderEventsRequest { + return new ListLoaderEventsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListLoaderEventsRequest { + return new ListLoaderEventsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListLoaderEventsRequest | PlainMessage | undefined, b: ListLoaderEventsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListLoaderEventsRequest, a, b); + } +} + +/** + * @generated from message agentcompose.v1.ListLoaderEventsResponse + */ +export class ListLoaderEventsResponse extends Message { + /** + * @generated from field: repeated agentcompose.v1.LoaderEvent events = 1; + */ + events: LoaderEvent[] = []; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.ListLoaderEventsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "events", kind: "message", T: LoaderEvent, repeated: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListLoaderEventsResponse { + return new ListLoaderEventsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListLoaderEventsResponse { + return new ListLoaderEventsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListLoaderEventsResponse { + return new ListLoaderEventsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListLoaderEventsResponse | PlainMessage | undefined, b: ListLoaderEventsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListLoaderEventsResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.DashboardOverviewResponse + */ +export class DashboardOverviewResponse extends Message { + /** + * @generated from field: agentcompose.v1.DashboardOverview overview = 1; + */ + overview?: DashboardOverview; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.DashboardOverviewResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "overview", kind: "message", T: DashboardOverview }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DashboardOverviewResponse { + return new DashboardOverviewResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DashboardOverviewResponse { + return new DashboardOverviewResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DashboardOverviewResponse { + return new DashboardOverviewResponse().fromJsonString(jsonString, options); + } + + static equals(a: DashboardOverviewResponse | PlainMessage | undefined, b: DashboardOverviewResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DashboardOverviewResponse, a, b); + } +} + +/** + * @generated from message agentcompose.v1.DashboardOverviewEvent + */ +export class DashboardOverviewEvent extends Message { + /** + * @generated from field: agentcompose.v1.DashboardOverview overview = 1; + */ + overview?: DashboardOverview; + + /** + * @generated from field: string reason = 2; + */ + reason = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.DashboardOverviewEvent"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "overview", kind: "message", T: DashboardOverview }, + { no: 2, name: "reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DashboardOverviewEvent { + return new DashboardOverviewEvent().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DashboardOverviewEvent { + return new DashboardOverviewEvent().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DashboardOverviewEvent { + return new DashboardOverviewEvent().fromJsonString(jsonString, options); + } + + static equals(a: DashboardOverviewEvent | PlainMessage | undefined, b: DashboardOverviewEvent | PlainMessage | undefined): boolean { + return proto3.util.equals(DashboardOverviewEvent, a, b); + } +} + +/** + * @generated from message agentcompose.v1.DashboardOverview + */ +export class DashboardOverview extends Message { + /** + * @generated from field: agentcompose.v1.RunOverview runs = 1; + */ + runs?: RunOverview; + + /** + * @generated from field: string updated_at = 99; + */ + updatedAt = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.DashboardOverview"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "runs", kind: "message", T: RunOverview }, + { no: 99, name: "updated_at", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DashboardOverview { + return new DashboardOverview().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DashboardOverview { + return new DashboardOverview().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DashboardOverview { + return new DashboardOverview().fromJsonString(jsonString, options); + } + + static equals(a: DashboardOverview | PlainMessage | undefined, b: DashboardOverview | PlainMessage | undefined): boolean { + return proto3.util.equals(DashboardOverview, a, b); + } +} + +/** + * @generated from message agentcompose.v1.RunOverview + */ +export class RunOverview extends Message { + /** + * @generated from field: uint32 running_count = 1; + */ + runningCount = 0; + + /** + * @generated from field: uint32 recent_count = 2; + */ + recentCount = 0; + + /** + * @generated from field: uint32 attention_count = 3; + */ + attentionCount = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "agentcompose.v1.RunOverview"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "running_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "recent_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 3, name: "attention_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): RunOverview { + return new RunOverview().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): RunOverview { + return new RunOverview().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): RunOverview { + return new RunOverview().fromJsonString(jsonString, options); + } + + static equals(a: RunOverview | PlainMessage | undefined, b: RunOverview | PlainMessage | undefined): boolean { + return proto3.util.equals(RunOverview, a, b); + } +} + diff --git a/src/gen/agentcompose/v2/agentcompose_connect.ts b/src/gen/agentcompose/v2/agentcompose_connect.ts index b854a08..72a3460 100644 --- a/src/gen/agentcompose/v2/agentcompose_connect.ts +++ b/src/gen/agentcompose/v2/agentcompose_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { ApplyProjectRequest, ApplyProjectResponse, BuildImageEvent, BuildImageRequest, CapabilityStatusResponse, CreateVolumeRequest, CreateVolumeResponse, CreateWorkspacePresetRequest, DeleteWorkspacePresetRequest, DeleteWorkspacePresetResponse, ExecAttachRequest, ExecAttachResponse, ExecRequest, ExecResponse, ExecStreamResponse, FollowRunLogsRequest, GenerateLLMRequest, GenerateLLMResponse, GetCapabilityCatalogRequest, GetCapabilityCatalogResponse, GetCapabilityGatewayConfigRequest, GetCapabilityGatewayConfigResponse, GetCapabilityStatusRequest, GetDashboardOverviewRequest, GetDashboardOverviewResponse, GetGlobalEnvRequest, GetGlobalEnvResponse, GetProjectRequest, GetProjectResponse, GetRunRequest, GetRunResponse, GetSandboxRequest, GetSandboxResponse, GetSandboxStatsRequest, GetSandboxStatsResponse, GetSchedulerRequest, GetSchedulerResponse, GetSchedulerRunRequest, GetSchedulerRunResponse, InspectCacheRequest, InspectCacheResponse, InspectImageRequest, InspectImageResponse, InspectVolumeRequest, InspectVolumeResponse, ListCachesRequest, ListCachesResponse, ListCapabilitySetsRequest, ListCapabilitySetsResponse, ListImagesRequest, ListImagesResponse, ListProjectsRequest, ListProjectsResponse, ListRunEventsRequest, ListRunEventsResponse, ListRunsRequest, ListRunsResponse, ListSandboxesRequest, ListSandboxesResponse, ListSandboxHistoryRequest, ListSandboxHistoryResponse, ListSandboxRunEventsRequest, ListSandboxRunEventsResponse, ListSchedulerEventsRequest, ListSchedulerEventsResponse, ListSchedulerRunsRequest, ListSchedulerRunsResponse, ListSchedulersRequest, ListSchedulersResponse, ListVolumesRequest, ListVolumesResponse, ListWorkspacePresetsRequest, ListWorkspacePresetsResponse, PruneCachesRequest, PruneCachesResponse, PruneSandboxesRequest, PruneSandboxesResponse, PruneVolumesRequest, PruneVolumesResponse, PullImageRequest, PullImageResponse, RemoveCacheRequest, RemoveCacheResponse, RemoveImageRequest, RemoveImageResponse, RemoveProjectRequest, RemoveProjectResponse, RemoveSandboxRequest, RemoveSandboxResponse, RemoveVolumeRequest, RemoveVolumeResponse, ResolveResourceIDRequest, ResolveResourceIDResponse, ResumeSandboxRequest, ResumeSandboxResponse, RunAgentRequest, RunAgentResponse, RunAgentStreamResponse, RunAttachRequest, RunAttachResponse, RunLogChunk, RunSchedulerRequest, RunSchedulerResponse, SetSchedulerEnabledRequest, SetSchedulerEnabledResponse, SetSchedulerTriggerEnabledRequest, SetSchedulerTriggerEnabledResponse, StartRunRequest, StartRunResponse, StartSchedulerRunRequest, StartSchedulerRunResponse, StopRunRequest, StopRunResponse, StopSandboxRequest, StopSandboxResponse, StopSchedulerRunRequest, StopSchedulerRunResponse, UpdateCapabilityGatewayConfigRequest, UpdateCapabilityGatewayConfigResponse, UpdateGlobalEnvRequest, UpdateGlobalEnvResponse, UpdateWorkspacePresetRequest, ValidateProjectRequest, ValidateProjectResponse, WatchDashboardOverviewRequest, WatchDashboardOverviewResponse, WatchProjectRequest, WatchProjectResponse, WatchSandboxRequest, WatchSandboxResponse, WorkspacePresetResponse } from "./agentcompose_pb.js"; +import { ApplyProjectRequest, ApplyProjectResponse, BuildImageEvent, BuildImageRequest, CapabilityStatusResponse, CreateVolumeRequest, CreateVolumeResponse, CreateWorkspacePresetRequest, DeleteWorkspacePresetRequest, DeleteWorkspacePresetResponse, ExecAttachRequest, ExecAttachResponse, ExecRequest, ExecResponse, ExecStreamResponse, FollowRunLogsRequest, GetCapabilityCatalogRequest, GetCapabilityCatalogResponse, GetCapabilityGatewayConfigRequest, GetCapabilityGatewayConfigResponse, GetCapabilityStatusRequest, GetDashboardOverviewRequest, GetDashboardOverviewResponse, GetGlobalEnvRequest, GetGlobalEnvResponse, GetProjectRequest, GetProjectResponse, GetRunRequest, GetRunResponse, GetSandboxRequest, GetSandboxResponse, GetSandboxStatsRequest, GetSandboxStatsResponse, GetSchedulerRequest, GetSchedulerResponse, GetSchedulerRunRequest, GetSchedulerRunResponse, InspectCacheRequest, InspectCacheResponse, InspectImageRequest, InspectImageResponse, InspectVolumeRequest, InspectVolumeResponse, ListCachesRequest, ListCachesResponse, ListCapabilitySetsRequest, ListCapabilitySetsResponse, ListImagesRequest, ListImagesResponse, ListProjectsRequest, ListProjectsResponse, ListRunEventsRequest, ListRunEventsResponse, ListRunsRequest, ListRunsResponse, ListSandboxesRequest, ListSandboxesResponse, ListSandboxHistoryRequest, ListSandboxHistoryResponse, ListSandboxRunEventsRequest, ListSandboxRunEventsResponse, ListSchedulerEventsRequest, ListSchedulerEventsResponse, ListSchedulerRunsRequest, ListSchedulerRunsResponse, ListSchedulersRequest, ListSchedulersResponse, ListVolumesRequest, ListVolumesResponse, ListWorkspacePresetsRequest, ListWorkspacePresetsResponse, PruneCachesRequest, PruneCachesResponse, PruneVolumesRequest, PruneVolumesResponse, PullImageRequest, PullImageResponse, RemoveCacheRequest, RemoveCacheResponse, RemoveImageRequest, RemoveImageResponse, RemoveProjectRequest, RemoveProjectResponse, RemoveSandboxRequest, RemoveSandboxResponse, RemoveVolumeRequest, RemoveVolumeResponse, ResumeSandboxRequest, ResumeSandboxResponse, RunAgentRequest, RunAgentResponse, RunAgentStreamResponse, RunAttachRequest, RunAttachResponse, RunLogChunk, RunSchedulerRequest, RunSchedulerResponse, SetSchedulerEnabledRequest, SetSchedulerEnabledResponse, SetSchedulerTriggerEnabledRequest, SetSchedulerTriggerEnabledResponse, StartRunRequest, StartRunResponse, StartSchedulerRunRequest, StartSchedulerRunResponse, StopRunRequest, StopRunResponse, StopSandboxRequest, StopSandboxResponse, StopSchedulerRunRequest, StopSchedulerRunResponse, UpdateCapabilityGatewayConfigRequest, UpdateCapabilityGatewayConfigResponse, UpdateGlobalEnvRequest, UpdateGlobalEnvResponse, UpdateWorkspacePresetRequest, ValidateProjectRequest, ValidateProjectResponse, WatchDashboardOverviewRequest, WatchDashboardOverviewResponse, WatchProjectRequest, WatchProjectResponse, WatchSandboxRequest, WatchSandboxResponse, WorkspacePresetResponse } from "./agentcompose_pb.js"; import { MethodKind } from "@bufbuild/protobuf"; /** @@ -470,15 +470,6 @@ export const SandboxService = { O: RemoveSandboxResponse, kind: MethodKind.Unary, }, - /** - * @generated from rpc agentcompose.v2.SandboxService.PruneSandboxes - */ - pruneSandboxes: { - name: "PruneSandboxes", - I: PruneSandboxesRequest, - O: PruneSandboxesResponse, - kind: MethodKind.Unary, - }, /** * @generated from rpc agentcompose.v2.SandboxService.GetSandboxStats */ @@ -689,38 +680,3 @@ export const CapabilityService = { } } as const; -/** - * @generated from service agentcompose.v2.LLMService - */ -export const LLMService = { - typeName: "agentcompose.v2.LLMService", - methods: { - /** - * @generated from rpc agentcompose.v2.LLMService.Generate - */ - generate: { - name: "Generate", - I: GenerateLLMRequest, - O: GenerateLLMResponse, - kind: MethodKind.Unary, - }, - } -} as const; - -/** - * @generated from service agentcompose.v2.ResourceService - */ -export const ResourceService = { - typeName: "agentcompose.v2.ResourceService", - methods: { - /** - * @generated from rpc agentcompose.v2.ResourceService.ResolveID - */ - resolveID: { - name: "ResolveID", - I: ResolveResourceIDRequest, - O: ResolveResourceIDResponse, - kind: MethodKind.Unary, - }, - } -} as const; diff --git a/src/gen/agentcompose/v2/agentcompose_pb.ts b/src/gen/agentcompose/v2/agentcompose_pb.ts index b41411c..37ab03e 100644 --- a/src/gen/agentcompose/v2/agentcompose_pb.ts +++ b/src/gen/agentcompose/v2/agentcompose_pb.ts @@ -645,9 +645,9 @@ export enum CacheDomain { RUNTIME_DERIVED_CACHE = 3, /** - * @generated from enum value: CACHE_DOMAIN_SKILL_ARTIFACT_CACHE = 5; + * @generated from enum value: CACHE_DOMAIN_SANDBOX_EPHEMERAL_STATE = 4; */ - SKILL_ARTIFACT_CACHE = 5, + SANDBOX_EPHEMERAL_STATE = 4, } // Retrieve enum metadata with: proto3.getEnumType(CacheDomain) proto3.util.setEnumType(CacheDomain, "agentcompose.v2.CacheDomain", [ @@ -655,59 +655,7 @@ proto3.util.setEnumType(CacheDomain, "agentcompose.v2.CacheDomain", [ { no: 1, name: "CACHE_DOMAIN_OCI_IMAGE_STORE" }, { no: 2, name: "CACHE_DOMAIN_MATERIALIZED_IMAGE_CACHE" }, { no: 3, name: "CACHE_DOMAIN_RUNTIME_DERIVED_CACHE" }, - { no: 5, name: "CACHE_DOMAIN_SKILL_ARTIFACT_CACHE" }, -]); - -/** - * @generated from enum agentcompose.v2.CacheReferencePolicy - */ -export enum CacheReferencePolicy { - /** - * @generated from enum value: CACHE_REFERENCE_POLICY_UNSPECIFIED = 0; - */ - UNSPECIFIED = 0, - - /** - * @generated from enum value: CACHE_REFERENCE_POLICY_REQUIRED = 1; - */ - REQUIRED = 1, - - /** - * @generated from enum value: CACHE_REFERENCE_POLICY_ADVISORY = 2; - */ - ADVISORY = 2, -} -// Retrieve enum metadata with: proto3.getEnumType(CacheReferencePolicy) -proto3.util.setEnumType(CacheReferencePolicy, "agentcompose.v2.CacheReferencePolicy", [ - { no: 0, name: "CACHE_REFERENCE_POLICY_UNSPECIFIED" }, - { no: 1, name: "CACHE_REFERENCE_POLICY_REQUIRED" }, - { no: 2, name: "CACHE_REFERENCE_POLICY_ADVISORY" }, -]); - -/** - * @generated from enum agentcompose.v2.SandboxPruneCandidateKind - */ -export enum SandboxPruneCandidateKind { - /** - * @generated from enum value: SANDBOX_PRUNE_CANDIDATE_KIND_UNSPECIFIED = 0; - */ - UNSPECIFIED = 0, - - /** - * @generated from enum value: SANDBOX_PRUNE_CANDIDATE_KIND_SANDBOX_RECORD = 1; - */ - SANDBOX_RECORD = 1, - - /** - * @generated from enum value: SANDBOX_PRUNE_CANDIDATE_KIND_RUNTIME_RESIDUE = 2; - */ - RUNTIME_RESIDUE = 2, -} -// Retrieve enum metadata with: proto3.getEnumType(SandboxPruneCandidateKind) -proto3.util.setEnumType(SandboxPruneCandidateKind, "agentcompose.v2.SandboxPruneCandidateKind", [ - { no: 0, name: "SANDBOX_PRUNE_CANDIDATE_KIND_UNSPECIFIED" }, - { no: 1, name: "SANDBOX_PRUNE_CANDIDATE_KIND_SANDBOX_RECORD" }, - { no: 2, name: "SANDBOX_PRUNE_CANDIDATE_KIND_RUNTIME_RESIDUE" }, + { no: 4, name: "CACHE_DOMAIN_SANDBOX_EPHEMERAL_STATE" }, ]); /** @@ -760,56 +708,6 @@ proto3.util.setEnumType(CacheStatus, "agentcompose.v2.CacheStatus", [ { no: 6, name: "CACHE_STATUS_UNKNOWN" }, ]); -/** - * @generated from enum agentcompose.v2.ResourceKind - */ -export enum ResourceKind { - /** - * @generated from enum value: RESOURCE_KIND_UNSPECIFIED = 0; - */ - UNSPECIFIED = 0, - - /** - * @generated from enum value: RESOURCE_KIND_PROJECT = 1; - */ - PROJECT = 1, - - /** - * @generated from enum value: RESOURCE_KIND_AGENT = 2; - */ - AGENT = 2, - - /** - * @generated from enum value: RESOURCE_KIND_RUN = 3; - */ - RUN = 3, - - /** - * @generated from enum value: RESOURCE_KIND_SANDBOX = 4; - */ - SANDBOX = 4, - - /** - * @generated from enum value: RESOURCE_KIND_IMAGE = 5; - */ - IMAGE = 5, - - /** - * @generated from enum value: RESOURCE_KIND_CACHE = 6; - */ - CACHE = 6, -} -// Retrieve enum metadata with: proto3.getEnumType(ResourceKind) -proto3.util.setEnumType(ResourceKind, "agentcompose.v2.ResourceKind", [ - { no: 0, name: "RESOURCE_KIND_UNSPECIFIED" }, - { no: 1, name: "RESOURCE_KIND_PROJECT" }, - { no: 2, name: "RESOURCE_KIND_AGENT" }, - { no: 3, name: "RESOURCE_KIND_RUN" }, - { no: 4, name: "RESOURCE_KIND_SANDBOX" }, - { no: 5, name: "RESOURCE_KIND_IMAGE" }, - { no: 6, name: "RESOURCE_KIND_CACHE" }, -]); - /** * @generated from enum agentcompose.v2.SandboxWatchEventType */ @@ -1828,16 +1726,6 @@ export class ProjectAgent extends Message { */ latestRun?: ProjectAgentLatestRun; - /** - * @generated from field: string display_name = 14; - */ - displayName = ""; - - /** - * @generated from field: string description = 15; - */ - description = ""; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -1859,8 +1747,6 @@ export class ProjectAgent extends Message { { no: 11, name: "health", kind: "enum", T: proto3.getEnumType(ProjectAgentHealth) }, { no: 12, name: "current_run", kind: "message", T: ProjectAgentCurrentRun }, { no: 13, name: "latest_run", kind: "message", T: ProjectAgentLatestRun }, - { no: 14, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 15, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ProjectAgent { @@ -2013,16 +1899,6 @@ export class ProjectScheduler extends Message { */ triggerCount = 0; - /** - * @generated from field: string display_name = 7; - */ - displayName = ""; - - /** - * @generated from field: string description = 8; - */ - description = ""; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -2036,8 +1912,6 @@ export class ProjectScheduler extends Message { { no: 3, name: "scheduler_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 5, name: "enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 6, name: "trigger_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, - { no: 7, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 8, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ProjectScheduler { @@ -2315,16 +2189,6 @@ export class SchedulerSummary extends Message { */ lastError = ""; - /** - * @generated from field: string display_name = 9; - */ - displayName = ""; - - /** - * @generated from field: string description = 10; - */ - description = ""; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -2341,8 +2205,6 @@ export class SchedulerSummary extends Message { { no: 6, name: "run_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 7, name: "latest_run_at", kind: "message", T: Timestamp }, { no: 8, name: "last_error", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 9, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 10, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): SchedulerSummary { @@ -3492,9 +3354,9 @@ export class ProjectSpec extends Message { workspaces: NamedWorkspaceSpec[] = []; /** - * @generated from field: repeated agentcompose.v2.MCPServerSpec mcp_servers = 8; + * @generated from field: repeated agentcompose.v2.MCPServerSpec mcps = 8; */ - mcpServers: MCPServerSpec[] = []; + mcps: MCPServerSpec[] = []; constructor(data?: PartialMessage) { super(); @@ -3510,7 +3372,7 @@ export class ProjectSpec extends Message { { no: 5, name: "network", kind: "message", T: NetworkSpec }, { no: 6, name: "volumes", kind: "message", T: ProjectVolumeSpec, repeated: true }, { no: 7, name: "workspaces", kind: "message", T: NamedWorkspaceSpec, repeated: true }, - { no: 8, name: "mcp_servers", kind: "message", T: MCPServerSpec, repeated: true }, + { no: 8, name: "mcps", kind: "message", T: MCPServerSpec, repeated: true }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ProjectSpec { @@ -3643,9 +3505,9 @@ export class AgentSpec extends Message { volumes: VolumeMountSpec[] = []; /** - * @generated from field: repeated agentcompose.v2.MCPServerSpec mcp_servers = 14; + * @generated from field: repeated agentcompose.v2.MCPServerSpec mcps = 14; */ - mcpServers: MCPServerSpec[] = []; + mcps: MCPServerSpec[] = []; /** * @generated from field: repeated agentcompose.v2.SkillSpec skills = 15; @@ -3657,16 +3519,6 @@ export class AgentSpec extends Message { */ status = AgentStatus.UNSPECIFIED; - /** - * @generated from field: string display_name = 17; - */ - displayName = ""; - - /** - * @generated from field: string description = 18; - */ - description = ""; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -3688,11 +3540,9 @@ export class AgentSpec extends Message { { no: 11, name: "jupyter", kind: "message", T: JupyterSpec }, { no: 12, name: "build", kind: "message", T: BuildSpec }, { no: 13, name: "volumes", kind: "message", T: VolumeMountSpec, repeated: true }, - { no: 14, name: "mcp_servers", kind: "message", T: MCPServerSpec, repeated: true }, + { no: 14, name: "mcps", kind: "message", T: MCPServerSpec, repeated: true }, { no: 15, name: "skills", kind: "message", T: SkillSpec, repeated: true }, { no: 16, name: "status", kind: "enum", T: proto3.getEnumType(AgentStatus) }, - { no: 17, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 18, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): AgentSpec { @@ -4119,11 +3969,6 @@ export class WorkspaceSpec extends Message { */ name = ""; - /** - * @generated from field: string commit = 6; - */ - commit = ""; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -4137,7 +3982,6 @@ export class WorkspaceSpec extends Message { { no: 3, name: "branch", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 4, name: "path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 5, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 6, name: "commit", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): WorkspaceSpec { @@ -4218,16 +4062,6 @@ export class SchedulerSpec extends Message { */ sandboxPolicy = ""; - /** - * @generated from field: string display_name = 5; - */ - displayName = ""; - - /** - * @generated from field: string description = 6; - */ - description = ""; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -4240,8 +4074,6 @@ export class SchedulerSpec extends Message { { no: 2, name: "triggers", kind: "message", T: TriggerSpec, repeated: true }, { no: 3, name: "script", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 4, name: "sandbox_policy", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 5, name: "display_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 6, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): SchedulerSpec { @@ -5908,231 +5740,6 @@ export class RemoveSandboxResponse extends Message { } } -/** - * @generated from message agentcompose.v2.PruneSandboxesRequest - */ -export class PruneSandboxesRequest extends Message { - /** - * @generated from field: string project_id = 1; - */ - projectId = ""; - - /** - * @generated from field: repeated string status = 2; - */ - status: string[] = []; - - /** - * @generated from field: string agent_name = 3; - */ - agentName = ""; - - /** - * @generated from field: string driver = 4; - */ - driver = ""; - - /** - * @generated from field: uint64 older_than_seconds = 5; - */ - olderThanSeconds = protoInt64.zero; - - /** - * @generated from field: bool include_orphans = 6; - */ - includeOrphans = false; - - /** - * @generated from field: bool force = 7; - */ - force = false; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.PruneSandboxesRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "project_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "status", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - { no: 3, name: "agent_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 5, name: "older_than_seconds", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, - { no: 6, name: "include_orphans", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, - { no: 7, name: "force", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): PruneSandboxesRequest { - return new PruneSandboxesRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): PruneSandboxesRequest { - return new PruneSandboxesRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): PruneSandboxesRequest { - return new PruneSandboxesRequest().fromJsonString(jsonString, options); - } - - static equals(a: PruneSandboxesRequest | PlainMessage | undefined, b: PruneSandboxesRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(PruneSandboxesRequest, a, b); - } -} - -/** - * @generated from message agentcompose.v2.SandboxPruneCandidate - */ -export class SandboxPruneCandidate extends Message { - /** - * @generated from field: agentcompose.v2.SandboxPruneCandidateKind kind = 1; - */ - kind = SandboxPruneCandidateKind.UNSPECIFIED; - - /** - * @generated from field: string sandbox_id = 2; - */ - sandboxId = ""; - - /** - * @generated from field: string project_id = 3; - */ - projectId = ""; - - /** - * @generated from field: string agent_name = 4; - */ - agentName = ""; - - /** - * @generated from field: string driver = 5; - */ - driver = ""; - - /** - * @generated from field: string status = 6; - */ - status = ""; - - /** - * @generated from field: string runtime_id = 7; - */ - runtimeId = ""; - - /** - * @generated from field: google.protobuf.Timestamp updated_at = 8; - */ - updatedAt?: Timestamp; - - /** - * @generated from field: bool removable = 9; - */ - removable = false; - - /** - * @generated from field: repeated string blocked_reasons = 10; - */ - blockedReasons: string[] = []; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.SandboxPruneCandidate"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "kind", kind: "enum", T: proto3.getEnumType(SandboxPruneCandidateKind) }, - { no: 2, name: "sandbox_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "project_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "agent_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 5, name: "driver", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 6, name: "status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 7, name: "runtime_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 8, name: "updated_at", kind: "message", T: Timestamp }, - { no: 9, name: "removable", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, - { no: 10, name: "blocked_reasons", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): SandboxPruneCandidate { - return new SandboxPruneCandidate().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): SandboxPruneCandidate { - return new SandboxPruneCandidate().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): SandboxPruneCandidate { - return new SandboxPruneCandidate().fromJsonString(jsonString, options); - } - - static equals(a: SandboxPruneCandidate | PlainMessage | undefined, b: SandboxPruneCandidate | PlainMessage | undefined): boolean { - return proto3.util.equals(SandboxPruneCandidate, a, b); - } -} - -/** - * @generated from message agentcompose.v2.PruneSandboxesResponse - */ -export class PruneSandboxesResponse extends Message { - /** - * @generated from field: bool dry_run = 1; - */ - dryRun = false; - - /** - * @generated from field: repeated agentcompose.v2.SandboxPruneCandidate matched = 2; - */ - matched: SandboxPruneCandidate[] = []; - - /** - * @generated from field: repeated string removed = 3; - */ - removed: string[] = []; - - /** - * @generated from field: repeated agentcompose.v2.SandboxPruneCandidate skipped = 4; - */ - skipped: SandboxPruneCandidate[] = []; - - /** - * @generated from field: repeated string warnings = 5; - */ - warnings: string[] = []; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.PruneSandboxesResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "dry_run", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, - { no: 2, name: "matched", kind: "message", T: SandboxPruneCandidate, repeated: true }, - { no: 3, name: "removed", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - { no: 4, name: "skipped", kind: "message", T: SandboxPruneCandidate, repeated: true }, - { no: 5, name: "warnings", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): PruneSandboxesResponse { - return new PruneSandboxesResponse().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): PruneSandboxesResponse { - return new PruneSandboxesResponse().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): PruneSandboxesResponse { - return new PruneSandboxesResponse().fromJsonString(jsonString, options); - } - - static equals(a: PruneSandboxesResponse | PlainMessage | undefined, b: PruneSandboxesResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(PruneSandboxesResponse, a, b); - } -} - /** * @generated from message agentcompose.v2.GetSandboxStatsRequest */ @@ -9202,6 +8809,11 @@ export class PruneCachesRequest extends Message { */ filter?: CacheFilter; + /** + * @generated from field: bool include_referenced = 2; + */ + includeReferenced = false; + /** * @generated from field: bool force = 3; */ @@ -9216,6 +8828,7 @@ export class PruneCachesRequest extends Message { static readonly typeName = "agentcompose.v2.PruneCachesRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "filter", kind: "message", T: CacheFilter }, + { no: 2, name: "include_referenced", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 3, name: "force", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, ]); @@ -9450,6 +9063,11 @@ export class CacheItem extends Message { */ resolvedRef = ""; + /** + * @generated from field: string sandbox_id = 11; + */ + sandboxId = ""; + /** * @generated from field: agentcompose.v2.CacheStatus status = 12; */ @@ -9502,6 +9120,7 @@ export class CacheItem extends Message { { no: 7, name: "image_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 8, name: "image_ref", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 9, name: "resolved_ref", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 11, name: "sandbox_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 12, name: "status", kind: "enum", T: proto3.getEnumType(CacheStatus) }, { no: 13, name: "removable", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 14, name: "blocked_reasons", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, @@ -9562,11 +9181,6 @@ export class CacheReference extends Message { */ description = ""; - /** - * @generated from field: agentcompose.v2.CacheReferencePolicy policy = 7; - */ - policy = CacheReferencePolicy.UNSPECIFIED; - constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -9581,7 +9195,6 @@ export class CacheReference extends Message { { no: 4, name: "path", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 5, name: "status", kind: "scalar", T: 9 /* ScalarType.STRING */ }, { no: 6, name: "description", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 7, name: "policy", kind: "enum", T: proto3.getEnumType(CacheReferencePolicy) }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): CacheReference { @@ -10811,159 +10424,6 @@ export class SkillSpec extends Message { } } -/** - * @generated from message agentcompose.v2.ResolveResourceIDRequest - */ -export class ResolveResourceIDRequest extends Message { - /** - * @generated from field: string id = 1; - */ - id = ""; - - /** - * @generated from field: repeated agentcompose.v2.ResourceKind kinds = 2; - */ - kinds: ResourceKind[] = []; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.ResolveResourceIDRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "kinds", kind: "enum", T: proto3.getEnumType(ResourceKind), repeated: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): ResolveResourceIDRequest { - return new ResolveResourceIDRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): ResolveResourceIDRequest { - return new ResolveResourceIDRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): ResolveResourceIDRequest { - return new ResolveResourceIDRequest().fromJsonString(jsonString, options); - } - - static equals(a: ResolveResourceIDRequest | PlainMessage | undefined, b: ResolveResourceIDRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(ResolveResourceIDRequest, a, b); - } -} - -/** - * @generated from message agentcompose.v2.ResolveResourceIDResponse - */ -export class ResolveResourceIDResponse extends Message { - /** - * @generated from field: repeated agentcompose.v2.ResourceTarget targets = 1; - */ - targets: ResourceTarget[] = []; - - /** - * @generated from field: repeated string warnings = 2; - */ - warnings: string[] = []; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.ResolveResourceIDResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "targets", kind: "message", T: ResourceTarget, repeated: true }, - { no: 2, name: "warnings", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): ResolveResourceIDResponse { - return new ResolveResourceIDResponse().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): ResolveResourceIDResponse { - return new ResolveResourceIDResponse().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): ResolveResourceIDResponse { - return new ResolveResourceIDResponse().fromJsonString(jsonString, options); - } - - static equals(a: ResolveResourceIDResponse | PlainMessage | undefined, b: ResolveResourceIDResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(ResolveResourceIDResponse, a, b); - } -} - -/** - * @generated from message agentcompose.v2.ResourceTarget - */ -export class ResourceTarget extends Message { - /** - * @generated from field: agentcompose.v2.ResourceKind kind = 1; - */ - kind = ResourceKind.UNSPECIFIED; - - /** - * @generated from field: string id = 2; - */ - id = ""; - - /** - * @generated from field: string short_id = 3; - */ - shortId = ""; - - /** - * @generated from field: string project_id = 4; - */ - projectId = ""; - - /** - * @generated from field: string project_name = 5; - */ - projectName = ""; - - /** - * @generated from field: string agent_name = 6; - */ - agentName = ""; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.ResourceTarget"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "kind", kind: "enum", T: proto3.getEnumType(ResourceKind) }, - { no: 2, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "short_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "project_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 5, name: "project_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 6, name: "agent_name", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): ResourceTarget { - return new ResourceTarget().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): ResourceTarget { - return new ResourceTarget().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): ResourceTarget { - return new ResourceTarget().fromJsonString(jsonString, options); - } - - static equals(a: ResourceTarget | PlainMessage | undefined, b: ResourceTarget | PlainMessage | undefined): boolean { - return proto3.util.equals(ResourceTarget, a, b); - } -} - /** * @generated from message agentcompose.v2.GetDashboardOverviewRequest */ @@ -12742,112 +12202,3 @@ export class WatchSandboxResponse extends Message { } } -/** - * @generated from message agentcompose.v2.GenerateLLMRequest - */ -export class GenerateLLMRequest extends Message { - /** - * @generated from field: string prompt = 1; - */ - prompt = ""; - - /** - * @generated from field: string model = 2; - */ - model = ""; - - /** - * @generated from field: string output_schema = 3; - */ - outputSchema = ""; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.GenerateLLMRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "prompt", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "output_schema", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): GenerateLLMRequest { - return new GenerateLLMRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): GenerateLLMRequest { - return new GenerateLLMRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): GenerateLLMRequest { - return new GenerateLLMRequest().fromJsonString(jsonString, options); - } - - static equals(a: GenerateLLMRequest | PlainMessage | undefined, b: GenerateLLMRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(GenerateLLMRequest, a, b); - } -} - -/** - * @generated from message agentcompose.v2.GenerateLLMResponse - */ -export class GenerateLLMResponse extends Message { - /** - * @generated from field: string text = 1; - */ - text = ""; - - /** - * @generated from field: string model = 2; - */ - model = ""; - - /** - * @generated from field: string response_id = 3; - */ - responseId = ""; - - /** - * @generated from field: string finish_reason = 4; - */ - finishReason = ""; - - /** - * @generated from field: string json = 5; - */ - json = ""; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "agentcompose.v2.GenerateLLMResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "text", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "model", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "response_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "finish_reason", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 5, name: "json", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): GenerateLLMResponse { - return new GenerateLLMResponse().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): GenerateLLMResponse { - return new GenerateLLMResponse().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): GenerateLLMResponse { - return new GenerateLLMResponse().fromJsonString(jsonString, options); - } - - static equals(a: GenerateLLMResponse | PlainMessage | undefined, b: GenerateLLMResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(GenerateLLMResponse, a, b); - } -} diff --git a/src/gen/health/v1/health_connect.ts b/src/gen/health/v1/health_connect.ts index 089c267..f815a82 100644 --- a/src/gen/health/v1/health_connect.ts +++ b/src/gen/health/v1/health_connect.ts @@ -32,3 +32,4 @@ export const HealthService = { }, } } as const; + diff --git a/src/gen/health/v1/health_pb.ts b/src/gen/health/v1/health_pb.ts index 5299e2d..7114c19 100644 --- a/src/gen/health/v1/health_pb.ts +++ b/src/gen/health/v1/health_pb.ts @@ -260,3 +260,4 @@ export class ProcessUsage extends Message { return proto3.util.equals(ProcessUsage, a, b); } } + diff --git a/src/lib/agent-owned-executions.test.ts b/src/lib/agent-owned-executions.test.ts new file mode 100644 index 0000000..36bd570 --- /dev/null +++ b/src/lib/agent-owned-executions.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest'; +import { RunSource, RunStatus, RunSummary, SchedulerEvent } from '../gen/agentcompose/v2/agentcompose_pb'; +import { stableProjectRunId } from './run-scheduler-evidence'; +import { + filterAgentOwnedExecutions, + groupSchedulerExecutions, + mergeAgentOwnedExecutions, +} from './agent-owned-executions'; + +const event = (data: Partial & { id: string; runId: string; type: string }, seconds: bigint) => new SchedulerEvent({ + level: 'info', triggerId: 'trigger-1', createdAt: { seconds }, ...data, +}); + +test('groups every Scheduler operation family into one owned execution', () => { + const events = [ + event({ id: 'start', runId: 'loader-1', type: 'loader.run.started' }, 1n), + event({ id: 'command', runId: 'loader-1', type: 'loader.command.completed', payloadJson: '{"sandboxId":"sandbox-1"}' }, 2n), + event({ id: 'llm', runId: 'loader-1', type: 'loader.llm.completed' }, 3n), + event({ id: 'agent', runId: 'loader-1', type: 'loader.agent.completed' }, 4n), + event({ id: 'publish', runId: 'loader-1', type: 'loader.event.published' }, 5n), + event({ id: 'sandbox', runId: 'loader-1', type: 'loader.sandbox.rpc.completed' }, 6n), + event({ id: 'log', runId: 'loader-1', type: 'loader.log' }, 7n), + event({ id: 'warning', runId: 'loader-1', type: 'loader.deprecated_alias.warning', level: 'warning' }, 8n), + event({ id: 'done', runId: 'loader-1', type: 'loader.run.completed' }, 9n), + ]; + + const [execution] = groupSchedulerExecutions(events); + expect(execution).toMatchObject({ + schedulerRunId: 'loader-1', status: 'succeeded', triggerId: 'trigger-1', + startedAt: '1970-01-01T00:00:01.000Z', completedAt: '1970-01-01T00:00:09.000Z', + warningCount: 1, sandboxIds: ['sandbox-1'], historyComplete: true, + }); + expect(execution.capabilities).toEqual(['agent', 'llm', 'command', 'event', 'sandbox', 'log']); +}); + +test('derives running, failed, and skipped lifecycle states without message guessing', () => { + const grouped = groupSchedulerExecutions([ + event({ id: 'running', runId: 'running', type: 'loader.run.started' }, 1n), + event({ id: 'failed', runId: 'failed', type: 'loader.run.failed', level: 'error', message: 'boom' }, 2n), + event({ id: 'skipped', runId: 'skipped', type: 'loader.run.skipped', level: 'warn', message: 'busy' }, 3n), + ]); + expect(Object.fromEntries(grouped.map(item => [item.schedulerRunId, item.status]))).toEqual({ running: 'running', failed: 'failed', skipped: 'skipped' }); + expect(grouped.find(item => item.schedulerRunId === 'failed')?.error).toBe('boom'); +}); + +test('merges current sequenced Scheduler Agent runs by exact stable ID only', async () => { + const firstLinkedId = await stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-multiple:agent:1'); + const secondLinkedId = await stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-multiple:agent:2'); + const legacyId = await stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-legacy'); + const projectRuns = [ + new RunSummary({ runId: firstLinkedId, agentName: 'worker', source: RunSource.SCHEDULER, status: RunStatus.SUCCEEDED, startedAt: '2026-01-03T00:00:00Z' }), + new RunSummary({ runId: secondLinkedId, agentName: 'worker', source: RunSource.SCHEDULER, status: RunStatus.SUCCEEDED, startedAt: '2026-01-02T00:00:00Z' }), + new RunSummary({ runId: legacyId, agentName: 'worker', source: RunSource.SCHEDULER, status: RunStatus.SUCCEEDED, startedAt: '2026-01-01T00:00:00Z' }), + ]; + const scheduler = groupSchedulerExecutions([ + event({ id: 'first', runId: 'loader-multiple', type: 'loader.agent.completed' }, 1n), + event({ id: 'second', runId: 'loader-multiple', type: 'loader.agent.failed', level: 'error' }, 2n), + event({ id: 'legacy', runId: 'loader-legacy', type: 'loader.agent.completed' }, 3n), + event({ id: 'other', runId: 'loader-other', type: 'loader.command.completed' }, 4n), + ]); + + const merged = await mergeAgentOwnedExecutions(projectRuns, scheduler, { projectId: 'project-1', agentName: 'worker' }); + expect(merged).toHaveLength(5); + expect(merged.find(item => item.projectRunId === firstLinkedId)).toMatchObject({ schedulerRunId: 'loader-multiple', kind: 'project-run' }); + expect(merged.find(item => item.projectRunId === secondLinkedId)).toMatchObject({ schedulerRunId: 'loader-multiple', kind: 'project-run' }); + expect(merged.find(item => item.projectRunId === legacyId)).toMatchObject({ schedulerRunId: '', kind: 'project-run' }); + expect(merged.find(item => item.id === 'scheduler:loader-legacy')).toMatchObject({ kind: 'scheduler-run' }); + expect(merged.find(item => item.schedulerRunId === 'loader-other')).toMatchObject({ kind: 'scheduler-run' }); +}); + +test('merges the next sequenced Agent Run while its Scheduler execution is still running', async () => { + const firstLinkedId = await stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-running:agent:1'); + const secondLinkedId = await stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-running:agent:2'); + const projectRuns = [ + new RunSummary({ runId: firstLinkedId, agentName: 'worker', source: RunSource.SCHEDULER, status: RunStatus.SUCCEEDED }), + new RunSummary({ runId: secondLinkedId, agentName: 'worker', source: RunSource.SCHEDULER, status: RunStatus.RUNNING }), + ]; + const scheduler = groupSchedulerExecutions([ + event({ id: 'start', runId: 'loader-running', type: 'loader.run.started' }, 1n), + event({ id: 'first', runId: 'loader-running', type: 'loader.agent.completed' }, 2n), + ]); + + const merged = await mergeAgentOwnedExecutions(projectRuns, scheduler, { projectId: 'project-1', agentName: 'worker' }); + + expect(merged).toHaveLength(2); + expect(merged.find(item => item.projectRunId === firstLinkedId)).toMatchObject({ schedulerRunId: 'loader-running' }); + expect(merged.find(item => item.projectRunId === secondLinkedId)).toMatchObject({ schedulerRunId: 'loader-running' }); +}); + +describe('unified filters', () => { + test('Scheduler source includes every Scheduler execution and applies status, date, and Sandbox exactly', async () => { + const items = await mergeAgentOwnedExecutions([], groupSchedulerExecutions([ + event({ id: 'start', runId: 'loader-1', type: 'loader.run.started', payloadJson: '{"sandboxId":"sandbox-1"}' }, 1n), + event({ id: 'done', runId: 'loader-1', type: 'loader.run.completed' }, 2n), + event({ id: 'other', runId: 'loader-2', type: 'loader.llm.completed' }, 3n), + ]), { projectId: 'project-1', agentName: 'worker' }); + + expect(filterAgentOwnedExecutions(items, { source: RunSource.SCHEDULER, status: RunStatus.SUCCEEDED, startedFrom: '', startedTo: '', sandboxId: 'sandbox-1' }).map(item => item.schedulerRunId)).toEqual(['loader-1']); + expect(filterAgentOwnedExecutions(items, { source: RunSource.MANUAL, status: RunStatus.UNSPECIFIED, startedFrom: '', startedTo: '', sandboxId: '' })).toEqual([]); + }); +}); diff --git a/src/lib/agent-owned-executions.ts b/src/lib/agent-owned-executions.ts new file mode 100644 index 0000000..1001c02 --- /dev/null +++ b/src/lib/agent-owned-executions.ts @@ -0,0 +1,220 @@ +import { RunSource, RunStatus, type RunSummary, type SchedulerEvent } from '../gen/agentcompose/v2/agentcompose_pb'; +import { stableProjectRunId } from './run-scheduler-evidence'; + +export type SchedulerExecutionStatus = 'running' | 'succeeded' | 'failed' | 'skipped' | 'unknown'; +export type SchedulerCapability = 'agent' | 'llm' | 'command' | 'event' | 'sandbox' | 'log'; + +export interface SchedulerOwnedExecution { + schedulerRunId: string; + triggerId: string; + status: SchedulerExecutionStatus; + startedAt: string; + completedAt: string; + durationMs: number; + capabilities: SchedulerCapability[]; + sandboxIds: string[]; + warningCount: number; + error: string; + events: SchedulerEvent[]; + historyComplete: boolean; +} + +export interface AgentOwnedExecution { + id: string; + kind: 'project-run' | 'scheduler-run'; + projectRunId: string; + schedulerRunId: string; + triggerId: string; + source: RunSource; + status: SchedulerExecutionStatus | 'pending' | 'canceled'; + startedAt: string; + completedAt: string; + durationMs: number; + capabilities: SchedulerCapability[]; + sandboxIds: string[]; + warningCount: number; + error: string; + projectRun?: RunSummary; + schedulerExecution?: SchedulerOwnedExecution; +} + +export interface AgentOwnedExecutionFilters { + source: RunSource; + status: RunStatus; + startedFrom: string; + startedTo: string; + sandboxId: string; +} + +function eventTimestamp(event: SchedulerEvent): string { + if (!event.createdAt) return ''; + return new Date(Number(event.createdAt.seconds) * 1000 + event.createdAt.nanos / 1_000_000).toISOString(); +} + +const capabilityOrder: SchedulerCapability[] = ['agent', 'llm', 'command', 'event', 'sandbox', 'log']; + +export function schedulerEventCapabilities(events: readonly SchedulerEvent[]): SchedulerCapability[] { + const found = new Set(); + for (const event of events) { + if (event.type.startsWith('loader.agent.')) found.add('agent'); + else if (event.type.startsWith('loader.llm.')) found.add('llm'); + else if (event.type.startsWith('loader.command.')) found.add('command'); + else if (event.type.startsWith('loader.event.')) found.add('event'); + else if (event.type.startsWith('loader.sandbox.')) found.add('sandbox'); + else if (event.type === 'loader.log') found.add('log'); + } + return capabilityOrder.filter(value => found.has(value)); +} + +function collectSandboxIds(value: unknown, target: Set) { + if (Array.isArray(value)) { + for (const item of value) collectSandboxIds(item, target); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, child] of Object.entries(value)) { + if ((key === 'sandboxId' || key === 'sandbox_id') && typeof child === 'string' && child.trim()) target.add(child.trim()); + else collectSandboxIds(child, target); + } +} + +function schedulerSandboxIds(events: readonly SchedulerEvent[]): string[] { + const ids = new Set(); + for (const event of events) { + try { collectSandboxIds(JSON.parse(event.payloadJson || '{}'), ids); } catch {} + } + return [...ids]; +} + +export function groupSchedulerExecutions(events: readonly SchedulerEvent[]): SchedulerOwnedExecution[] { + const groups = new Map(); + for (const event of events) { + if (!event.runId) continue; + const group = groups.get(event.runId) ?? []; + group.push(event); + groups.set(event.runId, group); + } + const results: SchedulerOwnedExecution[] = []; + for (const [schedulerRunId, values] of groups) { + const ordered = [...values].sort((left, right) => eventTimestamp(left).localeCompare(eventTimestamp(right)) || left.id.localeCompare(right.id)); + const started = ordered.find(event => event.type === 'loader.run.started'); + const terminal = [...ordered].reverse().find(event => ['loader.run.completed', 'loader.run.failed', 'loader.run.skipped'].includes(event.type)); + let status: SchedulerExecutionStatus = started ? 'running' : 'unknown'; + if (terminal?.type === 'loader.run.completed') status = 'succeeded'; + if (terminal?.type === 'loader.run.failed') status = 'failed'; + if (terminal?.type === 'loader.run.skipped') status = 'skipped'; + const startedAt = started ? eventTimestamp(started) : ''; + const completedAt = terminal ? eventTimestamp(terminal) : ''; + const startMs = Date.parse(startedAt); + const endMs = Date.parse(completedAt); + results.push({ + schedulerRunId, + triggerId: ordered.find(event => event.triggerId)?.triggerId ?? '', + status, + startedAt, + completedAt, + durationMs: Number.isNaN(startMs) || Number.isNaN(endMs) ? 0 : Math.max(0, endMs - startMs), + capabilities: schedulerEventCapabilities(ordered), + sandboxIds: schedulerSandboxIds(ordered), + warningCount: ordered.filter(event => /warn/i.test(event.level) || event.type.includes('.warning')).length, + error: [...ordered].reverse().find(event => /error|fatal/i.test(event.level) || event.type.endsWith('.failed'))?.message ?? '', + events: ordered, + historyComplete: Boolean(started), + }); + } + return results.sort((left, right) => right.startedAt.localeCompare(left.startedAt) || right.schedulerRunId.localeCompare(left.schedulerRunId)); +} + +function projectStatus(status: RunStatus): AgentOwnedExecution['status'] { + if (status === RunStatus.PENDING) return 'pending'; + if (status === RunStatus.RUNNING) return 'running'; + if (status === RunStatus.SUCCEEDED) return 'succeeded'; + if (status === RunStatus.FAILED) return 'failed'; + if (status === RunStatus.CANCELED) return 'canceled'; + return 'unknown'; +} + +export async function mergeAgentOwnedExecutions( + projectRuns: readonly RunSummary[], + schedulerExecutions: readonly SchedulerOwnedExecution[], + context: { projectId: string; agentName: string }, +): Promise { + const schedulerByProjectRun = new Map(); + for (const execution of schedulerExecutions) { + const terminalAgentRunCount = execution.events.filter(event => event.type === 'loader.agent.completed' || event.type === 'loader.agent.failed').length; + const agentRunCount = terminalAgentRunCount + Number(execution.status === 'running'); + for (let sequence = 1; sequence <= agentRunCount; sequence++) { + const clientRequestId = `${execution.schedulerRunId}:agent:${sequence}`; + schedulerByProjectRun.set(await stableProjectRunId(context.projectId, context.agentName, 'scheduler', clientRequestId), execution); + } + } + const consumed = new Set(); + const merged: AgentOwnedExecution[] = projectRuns.map(run => { + const scheduler = schedulerByProjectRun.get(run.runId); + if (scheduler) consumed.add(scheduler.schedulerRunId); + return { + id: run.runId, + kind: 'project-run', + projectRunId: run.runId, + schedulerRunId: scheduler?.schedulerRunId ?? '', + triggerId: run.triggerId || scheduler?.triggerId || '', + source: run.source, + status: projectStatus(run.status), + startedAt: run.startedAt || scheduler?.startedAt || '', + completedAt: run.completedAt || scheduler?.completedAt || '', + durationMs: Number(run.durationMs) || scheduler?.durationMs || 0, + capabilities: scheduler?.capabilities ?? [], + sandboxIds: [...new Set([run.sandboxId, ...(scheduler?.sandboxIds ?? [])].filter(Boolean))], + warningCount: run.warnings.length + (scheduler?.warningCount ?? 0), + error: run.error || scheduler?.error || '', + projectRun: run, + schedulerExecution: scheduler, + }; + }); + for (const scheduler of schedulerExecutions) { + if (consumed.has(scheduler.schedulerRunId)) continue; + merged.push({ + id: `scheduler:${scheduler.schedulerRunId}`, + kind: 'scheduler-run', + projectRunId: '', + schedulerRunId: scheduler.schedulerRunId, + triggerId: scheduler.triggerId, + source: RunSource.SCHEDULER, + status: scheduler.status, + startedAt: scheduler.startedAt, + completedAt: scheduler.completedAt, + durationMs: scheduler.durationMs, + capabilities: scheduler.capabilities, + sandboxIds: scheduler.sandboxIds, + warningCount: scheduler.warningCount, + error: scheduler.error, + schedulerExecution: scheduler, + }); + } + return merged.sort((left, right) => right.startedAt.localeCompare(left.startedAt) || right.id.localeCompare(left.id)); +} + +function runStatusFilter(status: AgentOwnedExecution['status']): RunStatus { + if (status === 'pending') return RunStatus.PENDING; + if (status === 'running') return RunStatus.RUNNING; + if (status === 'succeeded') return RunStatus.SUCCEEDED; + if (status === 'failed') return RunStatus.FAILED; + if (status === 'canceled') return RunStatus.CANCELED; + return RunStatus.UNSPECIFIED; +} + +export function filterAgentOwnedExecutions(items: readonly AgentOwnedExecution[], filters: AgentOwnedExecutionFilters): AgentOwnedExecution[] { + const from = filters.startedFrom ? new Date(`${filters.startedFrom}T00:00:00`).getTime() : Number.NEGATIVE_INFINITY; + const to = filters.startedTo ? new Date(`${filters.startedTo}T23:59:59.999`).getTime() : Number.POSITIVE_INFINITY; + const sandbox = filters.sandboxId.trim(); + return items.filter(item => { + if (filters.source !== RunSource.UNSPECIFIED && item.source !== filters.source) return false; + if (filters.status !== RunStatus.UNSPECIFIED && runStatusFilter(item.status) !== filters.status) return false; + if (sandbox && !item.sandboxIds.includes(sandbox)) return false; + if (filters.startedFrom || filters.startedTo) { + const started = Date.parse(item.startedAt); + if (Number.isNaN(started) || started < from || started > to) return false; + } + return true; + }); +} diff --git a/src/lib/agent-run-transcript.test.ts b/src/lib/agent-run-transcript.test.ts new file mode 100644 index 0000000..393a97c --- /dev/null +++ b/src/lib/agent-run-transcript.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + joinAgentRunTranscriptSegments, + listAllRunEvents, + mapRunEventsToTranscript, + parseAgentRunTranscript, +} from './agent-run-transcript'; +import { + ListRunEventsResponse, + RunEvent, + RunEventKind, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +describe('agent run transcript', () => { + it('classifies known runtime markers without losing a single character', () => { + const input = [ + '先分析问题\n', + '$ bun test\npassed\n', + '[tool:Read]\n{"path":"a.ts"}\n\n', + '[mcp:server/search]\n{"q":"x"}\nresult\n', + '[file_change]\nupdate: a.ts\n', + '[todo]\n[x] inspect\n', + '[web_search] agent compose\n', + 'ERROR: request failed\n', + ].join(''); + + const segments = parseAgentRunTranscript(input); + + expect(segments.map((segment) => segment.kind)).toEqual([ + 'process', 'command', 'tool', 'tool', 'file', 'process', 'tool', 'diagnostic', + ]); + expect(joinAgentRunTranscriptSegments(segments)).toBe(input); + }); + + it('keeps unknown and provider-specific text as process output', () => { + const input = 'Model metadata missing\nprovider-specific payload\n'; + expect(parseAgentRunTranscript(input)).toEqual([{ kind: 'process', label: '执行输出', content: input }]); + }); + + it('ends a tool block at its blank separator and keeps following assistant text visible', () => { + const input = '[tool:Read]\n{"path":"a.ts"}\n\n这是最终回答\n'; + const segments = parseAgentRunTranscript(input); + expect(segments).toEqual([ + { kind: 'tool', label: '工具 · Read', content: '[tool:Read]\n{"path":"a.ts"}\n\n' }, + { kind: 'process', label: '执行输出', content: '这是最终回答\n' }, + ]); + expect(joinAgentRunTranscriptSegments(segments)).toBe(input); + }); +}); + +describe('structured run events', () => { + it('loads every cursor page and preserves backend history availability', async () => { + const requests: Array<{ runId: string; cursor: string; limit: number }> = []; + const pages = [ + new ListRunEventsResponse({ + events: [new RunEvent({ id: 'event-2', seq: 2n })], + nextCursor: 'page-2', + historyAvailable: true, + }), + new ListRunEventsResponse({ + events: [new RunEvent({ id: 'event-1', seq: 1n })], + historyAvailable: true, + }), + ]; + + const result = await listAllRunEvents('run-1', async (request) => { + requests.push({ runId: request.runId, cursor: request.cursor, limit: request.limit }); + return pages.shift()!; + }); + + expect(requests).toEqual([ + { runId: 'run-1', cursor: '', limit: 100 }, + { runId: 'run-1', cursor: 'page-2', limit: 100 }, + ]); + expect(result.historyAvailable).toBe(true); + expect(result.events.map((event) => event.id)).toEqual(['event-1', 'event-2']); + }); + + it('maps event labels and structured command, agent, and stop evidence directly', () => { + const entries = mapRunEventsToTranscript([ + new RunEvent({ id: 'status', seq: 4n, kind: RunEventKind.STATUS, success: false, exitCode: 17, stopReason: 'timeout' }), + new RunEvent({ id: 'agent', seq: 3n, kind: RunEventKind.AGENT_MESSAGE, agent: 'reviewer', text: 'final answer' }), + new RunEvent({ id: 'command', seq: 2n, kind: RunEventKind.AGENT_ACTIVITY, name: 'command', payloadJson: '{"command":"bun test"}', success: true, exitCode: 0 }), + new RunEvent({ id: 'user', seq: 1n, kind: RunEventKind.USER_MESSAGE, text: 'ship it' }), + ]); + + expect(entries.map((entry) => entry.label)).toEqual(['用户消息', '命令执行', '智能体消息 · reviewer', '运行状态']); + expect(entries[1].content).toContain('{"command":"bun test"}'); + expect(entries[1].content).toContain('成功 · 退出码 0'); + expect(entries[2].content).toBe('final answer'); + expect(entries[3].content).toContain('失败 · 退出码 17'); + expect(entries[3].content).toContain('停止原因:timeout'); + }); + + it('sorts equal-timestamp structured events by sequence', () => { + const entries = mapRunEventsToTranscript([ + new RunEvent({ id: 'second', seq: 2n, text: 'second' }), + new RunEvent({ id: 'first', seq: 1n, text: 'first' }), + ]); + expect(entries.map((entry) => entry.content)).toEqual(['first', 'second']); + }); + + it('reports unavailable history so callers can explicitly use inferred log fallback', async () => { + const result = await listAllRunEvents('legacy-run', async () => new ListRunEventsResponse({ historyAvailable: false })); + expect(result).toEqual({ events: [], historyAvailable: false }); + }); + + it('reports incomplete history when any fetched page marks history unavailable', async () => { + const pages = [ + new ListRunEventsResponse({ nextCursor: 'page-2', historyAvailable: true }), + new ListRunEventsResponse({ historyAvailable: false }), + ]; + + const result = await listAllRunEvents('partially-retained-run', async () => pages.shift()!); + + expect(result.historyAvailable).toBe(false); + }); + + it('stops on a repeated cursor while preserving incomplete-history evidence', async () => { + const cursors: string[] = []; + const pages = [ + new ListRunEventsResponse({ nextCursor: 'page-2', historyAvailable: true }), + new ListRunEventsResponse({ nextCursor: 'page-2', historyAvailable: false }), + ]; + + const result = await listAllRunEvents('looping-run', async (request) => { + cursors.push(request.cursor); + return pages.shift()!; + }); + + expect(cursors).toEqual(['', 'page-2']); + expect(result.historyAvailable).toBe(false); + }); +}); diff --git a/src/lib/agent-run-transcript.ts b/src/lib/agent-run-transcript.ts new file mode 100644 index 0000000..7631de8 --- /dev/null +++ b/src/lib/agent-run-transcript.ts @@ -0,0 +1,135 @@ +import { + ListRunEventsRequest, + RunEventKind, + type ListRunEventsResponse, + type RunEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export type AgentRunTranscriptKind = 'process' | 'command' | 'tool' | 'file' | 'diagnostic'; + +export interface AgentRunTranscriptSegment { + kind: AgentRunTranscriptKind; + label: string; + content: string; +} + +export interface StructuredRunTranscriptEntry extends AgentRunTranscriptSegment { + id: string; + seq: bigint; + timestamp: string; + agent: string; + name: string; +} + +export async function listAllRunEvents( + runId: string, + fetchPage: (request: ListRunEventsRequest) => Promise, +): Promise<{ events: RunEvent[]; historyAvailable: boolean }> { + const events: RunEvent[] = []; + const seenCursors = new Set(); + let cursor = ''; + let historyAvailable = true; + + do { + const response = await fetchPage(new ListRunEventsRequest({ runId, limit: 100, cursor })); + events.push(...response.events); + historyAvailable &&= response.historyAvailable; + if (!response.nextCursor || seenCursors.has(response.nextCursor)) break; + seenCursors.add(response.nextCursor); + cursor = response.nextCursor; + } while (cursor); + + return { + events: events.sort((left, right) => left.seq < right.seq ? -1 : left.seq > right.seq ? 1 : 0), + historyAvailable, + }; +} + +function structuredLabel(event: RunEvent): Pick { + switch (event.kind) { + case RunEventKind.USER_MESSAGE: + return { kind: 'process', label: '用户消息' }; + case RunEventKind.AGENT_MESSAGE: + return { kind: 'process', label: event.agent ? `智能体消息 · ${event.agent}` : '智能体消息' }; + case RunEventKind.AGENT_ACTIVITY: + return event.name.toLowerCase().includes('command') + ? { kind: 'command', label: '命令执行' } + : { kind: 'tool', label: event.name ? `智能体活动 · ${event.name}` : '智能体活动' }; + case RunEventKind.STATUS: + return { kind: event.success ? 'process' : 'diagnostic', label: '运行状态' }; + default: + return { kind: 'process', label: event.name || '运行事件' }; + } +} + +function eventTimestamp(event: RunEvent): string { + if (!event.createdAt) return ''; + return new Date(Number(event.createdAt.seconds) * 1000 + event.createdAt.nanos / 1_000_000).toISOString(); +} + +function eventContent(event: RunEvent): string { + const parts = [event.text, event.payloadJson]; + if (event.kind === RunEventKind.AGENT_ACTIVITY || event.kind === RunEventKind.STATUS) { + parts.push(`${event.success ? '成功' : '失败'} · 退出码 ${event.exitCode}`); + } + if (event.stopReason) parts.push(`停止原因:${event.stopReason}`); + return parts.filter(Boolean).join('\n'); +} + +export function mapRunEventsToTranscript(events: RunEvent[]): StructuredRunTranscriptEntry[] { + return [...events] + .sort((left, right) => left.seq < right.seq ? -1 : left.seq > right.seq ? 1 : 0) + .map((event) => ({ + ...structuredLabel(event), + id: event.id || `run-event-${event.seq}`, + seq: event.seq, + timestamp: eventTimestamp(event), + agent: event.agent, + name: event.name, + content: eventContent(event), + })); +} + +function classify(line: string): Omit | null { + const text = line.replace(/\r?\n$/, ''); + if (/^\$\s+/.test(text)) return { kind: 'command', label: '命令执行' }; + const tool = text.match(/^\[tool:([^\]]+)]\s*$/); + if (tool) return { kind: 'tool', label: `工具 · ${tool[1]}` }; + const mcp = text.match(/^\[mcp:([^\]]+)]\s*$/); + if (mcp) return { kind: 'tool', label: `MCP · ${mcp[1]}` }; + if (/^\[web_search](?:\s|$)/.test(text)) return { kind: 'tool', label: 'Web Search' }; + if (/^\[file_change]\s*$/.test(text)) return { kind: 'file', label: '文件变更' }; + if (/^\[todo]\s*$/.test(text)) return { kind: 'process', label: '任务进度' }; + if (/^\[hook:[^\]]+]\s*$/.test(text)) return { kind: 'tool', label: 'Hook' }; + if (/^(?:error|fatal|panic)(?:\b|:)/i.test(text)) return { kind: 'diagnostic', label: '执行错误' }; + return null; +} + +export function parseAgentRunTranscript(content: string): AgentRunTranscriptSegment[] { + if (!content) return []; + const lines = content.match(/.*?(?:\r\n|\n|$)/g)?.filter(Boolean) ?? [content]; + const segments: AgentRunTranscriptSegment[] = []; + let blockEnded = false; + + for (const line of lines) { + const classified = classify(line); + if (classified) { + segments.push({ ...classified, content: line }); + blockEnded = false; + continue; + } + const current = segments.at(-1); + if (current && current.kind !== 'diagnostic' && !blockEnded) { + current.content += line; + } else { + segments.push({ kind: 'process', label: '执行输出', content: line }); + } + const active = segments.at(-1); + blockEnded = Boolean(active && active.kind !== 'process' && /^\s*$/.test(line)); + } + return segments; +} + +export function joinAgentRunTranscriptSegments(segments: AgentRunTranscriptSegment[]): string { + return segments.map((segment) => segment.content).join(''); +} diff --git a/src/lib/caches.ts b/src/lib/caches.ts new file mode 100644 index 0000000..daa5fa4 --- /dev/null +++ b/src/lib/caches.ts @@ -0,0 +1,27 @@ +import { CacheDomain, CacheStatus, type CacheItem } from '../gen/agentcompose/v2/agentcompose_pb'; + +export function cacheDomainLabel(value: CacheDomain): string { + return ({ + [CacheDomain.OCI_IMAGE_STORE]: 'OCI 镜像存储', + [CacheDomain.MATERIALIZED_IMAGE_CACHE]: '物化镜像缓存', + [CacheDomain.RUNTIME_DERIVED_CACHE]: '运行时派生缓存', + [CacheDomain.SANDBOX_EPHEMERAL_STATE]: '沙箱临时状态', + } as Record)[value] ?? '未指定'; +} + +export function cacheStatusLabel(value: CacheStatus): string { + return ({ + [CacheStatus.ACTIVE]: '活跃', [CacheStatus.REFERENCED]: '被引用', [CacheStatus.UNUSED]: '未使用', + [CacheStatus.EXPIRED]: '已过期', [CacheStatus.ORPHANED]: '孤立', + } as Record)[value] ?? '未知'; +} + +export function cacheId(item: CacheItem): string { return item.cacheId || '后端未提供'; } +export function formatBytes(value: bigint): string { + const size = Number(value); + if (!Number.isFinite(size)) return String(value); + if (size < 1024) return `${size} B`; + if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KiB`; + if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(1)} MiB`; + return `${(size / 1024 ** 3).toFixed(1)} GiB`; +} diff --git a/src/lib/clipboard.test.ts b/src/lib/clipboard.test.ts new file mode 100644 index 0000000..9ecac8e --- /dev/null +++ b/src/lib/clipboard.test.ts @@ -0,0 +1,32 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { copyText } from './clipboard'; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +test('uses the Clipboard API when it is available', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + await copyText('hello'); + + expect(writeText).toHaveBeenCalledWith('hello'); +}); + +test('falls back to a selected textarea when Clipboard API rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('permission denied')); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommand }); + + await copyText('fallback text'); + + expect(execCommand).toHaveBeenCalledWith('copy'); + expect(document.querySelector('textarea[data-clipboard-fallback]')).toBeNull(); +}); + +test('reports failure when neither copy mechanism succeeds', async () => { + vi.stubGlobal('navigator', {}); + Object.defineProperty(document, 'execCommand', { configurable: true, value: vi.fn().mockReturnValue(false) }); + + await expect(copyText('cannot copy')).rejects.toThrow('clipboard copy failed'); +}); diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..a12582f --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,26 @@ +export async function copyText(value: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + } catch { + // Clipboard API can be present but denied on non-secure origins; use the synchronous browser fallback. + } + + const textarea = document.createElement('textarea'); + textarea.dataset.clipboardFallback = ''; + textarea.value = value; + textarea.readOnly = true; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + try { + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, value.length); + if (!document.execCommand?.('copy')) throw new Error('clipboard copy failed'); + } finally { + textarea.remove(); + } +} diff --git a/src/lib/event-sandbox-links.test.ts b/src/lib/event-sandbox-links.test.ts new file mode 100644 index 0000000..f47116b --- /dev/null +++ b/src/lib/event-sandbox-links.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { loadEventSandboxLinks } from './event-sandbox-links'; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('loadEventSandboxLinks', () => { + test('normalizes current sandbox links and URL-encodes the event ID', async () => { + const fetcher = vi.fn(async () => response({ sandboxes: [{ + sandbox_id: 'sandbox-1', relation: 'created', loader_id: 'loader-1', run_id: 'run-1', + trigger_id: 'trigger-1', loader_event_id: 'loader-event-1', event_id: 'evt/a', created_at: '2026-07-17T01:00:00Z', + }] })); + + await expect(loadEventSandboxLinks('evt/a', fetcher)).resolves.toEqual([{ + sandboxId: 'sandbox-1', relation: 'created', loaderId: 'loader-1', runId: 'run-1', + triggerId: 'trigger-1', loaderEventId: 'loader-event-1', eventId: 'evt/a', createdAt: '2026-07-17T01:00:00Z', + }]); + expect(fetcher).toHaveBeenCalledWith('/api/events/evt%2Fa/sessions', expect.objectContaining({ headers: { Accept: 'application/json' } })); + }); + + test('accepts legacy sessions and keeps the newest duplicate link', async () => { + const fetcher = vi.fn(async () => response({ sessions: [ + { session_id: 'sandbox-1', relation: 'old', created_at: '2026-07-16T01:00:00Z' }, + { session_id: '', relation: 'ignored' }, + { session_id: 'sandbox-1', relation: 'new', created_at: '2026-07-17T01:00:00Z' }, + ] })); + + const links = await loadEventSandboxLinks('evt-1', fetcher); + + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ sandboxId: 'sandbox-1', relation: 'new' }); + }); + + test('returns an empty list for an empty or malformed collection', async () => { + await expect(loadEventSandboxLinks('evt-1', vi.fn(async () => response({ sandboxes: null })))).resolves.toEqual([]); + }); + + test('reports a non-success response without exposing its body', async () => { + await expect(loadEventSandboxLinks('evt-1', vi.fn(async () => response({ secret: 'hidden' }, 503)))) + .rejects.toThrow('加载 Event Session 失败(503)'); + }); +}); diff --git a/src/lib/event-sandbox-links.ts b/src/lib/event-sandbox-links.ts new file mode 100644 index 0000000..488a3cc --- /dev/null +++ b/src/lib/event-sandbox-links.ts @@ -0,0 +1,67 @@ +export interface EventSandboxLink { + sandboxId: string; + relation: string; + loaderId: string; + runId: string; + triggerId: string; + loaderEventId: string; + eventId: string; + createdAt: string; +} + +interface EventSandboxLinkWire { + sandbox_id?: string; + session_id?: string; + relation?: string; + loader_id?: string; + run_id?: string; + trigger_id?: string; + loader_event_id?: string; + event_id?: string; + created_at?: string; +} + +interface EventSandboxLinksWireResponse { + sandboxes?: EventSandboxLinkWire[] | null; + sessions?: EventSandboxLinkWire[] | null; +} + +function createdAtValue(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeLink(item: EventSandboxLinkWire): EventSandboxLink | null { + const sandboxId = (item.session_id || item.sandbox_id || '').trim(); + if (!sandboxId) return null; + return { + sandboxId, + relation: item.relation || '', + loaderId: item.loader_id || '', + runId: item.run_id || '', + triggerId: item.trigger_id || '', + loaderEventId: item.loader_event_id || '', + eventId: item.event_id || '', + createdAt: item.created_at || '', + }; +} + +export async function loadEventSandboxLinks( + eventId: string, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher(`/api/events/${encodeURIComponent(eventId)}/sessions`, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`加载 Event Session 失败(${response.status})`); + const body = await response.json() as EventSandboxLinksWireResponse; + const items = Array.isArray(body.sessions) ? body.sessions : Array.isArray(body.sandboxes) ? body.sandboxes : []; + const links = new Map(); + for (const item of items) { + const link = normalizeLink(item); + if (!link) continue; + const existing = links.get(link.sandboxId); + if (!existing || createdAtValue(link.createdAt) >= createdAtValue(existing.createdAt)) links.set(link.sandboxId, link); + } + return [...links.values()]; +} diff --git a/src/lib/full-yaml-e2e-fixture.test.ts b/src/lib/full-yaml-e2e-fixture.test.ts new file mode 100644 index 0000000..14ab6a7 --- /dev/null +++ b/src/lib/full-yaml-e2e-fixture.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, test } from 'vitest'; +import { parseYamlObject, yamlToSpec } from './yaml'; + +const directory = resolve(import.meta.dirname, '../../e2e/fixtures/full-yaml'); +const yamlText = readFileSync(resolve(directory, 'agent-compose.yml'), 'utf8'); +const schedulerScript = readFileSync(resolve(directory, 'scheduler.js'), 'utf8'); + +describe('persistent full YAML fixture', () => { + test('contains every requested YAML configuration branch', () => { + const source = parseYamlObject(yamlText) as any; + expect(source.variables.TEST_SUITE).toEqual({ value: 'full-yaml-e2e' }); + expect(source.network.mode).toBe('default'); + expect(source.workspaces['fixture-workspace']).toMatchObject({ provider: 'local' }); + expect(source.agents['build-workspace-agent'].build).toMatchObject({ + target: 'runtime', + args: { BUILD_MARKER: 'yaml-build-config-ok' }, + platforms: ['linux/amd64'], + no_cache: true, + }); + expect(source.agents['prompt-agent'].system_prompt).toContain('exactly the marker'); + expect(source.agents['trigger-agent'].scheduler.triggers.map((item: any) => item.name)).toEqual([ + 'interval-check', 'cron-check', 'timeout-check', 'event-check', + ]); + expect(source.agents['script-agent'].scheduler.script).toMatch(/^\$ref:/); + expect(schedulerScript).toContain('yaml-script-main-ok'); + }); + + test('expands the referenced script into a complete V2 project spec', () => { + const expanded = yamlText.replace( + '$ref:e2e-yaml-full-20260715t232500z/scheduler.js', + JSON.stringify(schedulerScript), + ); + const { spec, error } = yamlToSpec(expanded); + expect(error).toBeUndefined(); + expect(spec.agents).toHaveLength(4); + expect(spec.variables[0]).toMatchObject({ name: 'TEST_SUITE', value: 'full-yaml-e2e' }); + expect(spec.workspaces[0]).toMatchObject({ + name: 'fixture-workspace', + workspace: { provider: 'local' }, + }); + expect(spec.agents.find(agent => agent.name === 'build-workspace-agent')?.workspace?.name).toBe('fixture-workspace'); + expect(spec.agents.find(agent => agent.name === 'script-agent')?.scheduler?.script).toContain('yaml-script-main-ok'); + }); +}); diff --git a/src/lib/image-management.test.ts b/src/lib/image-management.test.ts new file mode 100644 index 0000000..62350a1 --- /dev/null +++ b/src/lib/image-management.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test, vi } from 'vitest'; +import { Image, ImageStoreKind, RemoveImageResponse } from '../gen/agentcompose/v2/agentcompose_pb'; +import { imageDisplayRef, imageSelectionKey, removeImagesSequentially } from './image-management'; + +function image(imageId: string, imageRef: string): Image { + return new Image({ imageId, imageRef, store: ImageStoreKind.DOCKER_DAEMON }); +} + +describe('image identity', () => { + test('uses stable display fallbacks and includes the store in selection keys', () => { + const tagged = new Image({ imageId: 'sha256:one', repoTags: ['demo:latest'], store: ImageStoreKind.DOCKER_DAEMON }); + const cached = new Image({ imageId: 'sha256:one', repoTags: ['demo:latest'], store: ImageStoreKind.OCI_CACHE }); + + expect(imageDisplayRef(tagged)).toBe('demo:latest'); + expect(imageSelectionKey(tagged)).not.toBe(imageSelectionKey(cached)); + }); +}); + +describe('removeImagesSequentially', () => { + test('continues after individual failures and preserves every outcome', async () => { + const images = [image('sha256:first', 'first:dev'), image('sha256:second', 'second:dev'), image('sha256:third', 'third:dev')]; + const calls: string[] = []; + const removeImage = vi.fn(async (request: { imageRef: string; force: boolean; pruneChildren: boolean }) => { + calls.push(request.imageRef); + expect(request.force).toBe(true); + expect(request.pruneChildren).toBe(false); + if (request.imageRef === 'second:dev') throw new Error('in use'); + return new RemoveImageResponse({ deletedIds: [`sha256:${request.imageRef.split(':')[0]}`] }); + }); + + const results = await removeImagesSequentially({ + images, + force: true, + pruneChildren: false, + client: { removeImage }, + }); + + expect(calls).toEqual(['first:dev', 'second:dev', 'third:dev']); + expect(results.map((result) => result.error)).toEqual(['', 'in use', '']); + expect(results[0].response?.deletedIds).toEqual(['sha256:first']); + }); +}); diff --git a/src/lib/image-management.ts b/src/lib/image-management.ts new file mode 100644 index 0000000..a419954 --- /dev/null +++ b/src/lib/image-management.ts @@ -0,0 +1,48 @@ +import { + RemoveImageRequest, + type Image, + type RemoveImageResponse, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export interface ImageRemovalClient { + removeImage(request: RemoveImageRequest): Promise; +} + +export interface ImageRemovalResult { + image: Image; + imageRef: string; + response?: RemoveImageResponse; + error: string; +} + +export function imageDisplayRef(image: Image): string { + return image.imageRef || image.repoTags[0] || image.resolvedRef || image.imageId || '后端未提供'; +} + +export function imageSelectionKey(image: Image): string { + return `${image.store}:${image.imageId}:${imageDisplayRef(image)}`; +} + +export async function removeImagesSequentially(options: { + images: Image[]; + force: boolean; + pruneChildren: boolean; + client: ImageRemovalClient; +}): Promise { + const results: ImageRemovalResult[] = []; + for (const image of options.images) { + const imageRef = imageDisplayRef(image); + try { + const response = await options.client.removeImage(new RemoveImageRequest({ + imageRef, + store: image.store, + force: options.force, + pruneChildren: options.pruneChildren, + })); + results.push({ image, imageRef, response, error: '' }); + } catch (cause) { + results.push({ image, imageRef, error: cause instanceof Error ? cause.message : String(cause) }); + } + } + return results; +} diff --git a/src/lib/images.test.js b/src/lib/images.test.js new file mode 100644 index 0000000..b86055f --- /dev/null +++ b/src/lib/images.test.js @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test'; +import { ImageAvailabilityStatus, ImageStoreKind } from '../gen/agentcompose/v2/agentcompose_pb'; +import { formatImageBytes, formatImagePlatform, imageAvailabilityLabel, imageStoreLabel } from './images'; + +test('formats image sizes using binary units', () => { + expect(formatImageBytes(0n)).toBe('0 B'); + expect(formatImageBytes(1536n)).toBe('1.5 KiB'); + expect(formatImageBytes(5n * 1024n * 1024n)).toBe('5 MiB'); +}); + +test('formats an image platform without empty segments', () => { + expect(formatImagePlatform({ os: 'linux', architecture: 'amd64', variant: '' })).toBe('linux/amd64'); + expect(formatImagePlatform(undefined)).toBe('后端未提供'); +}); + +test('maps image enums to concise Chinese labels', () => { + expect(imageStoreLabel(ImageStoreKind.DOCKER_DAEMON)).toBe('Docker daemon'); + expect(imageStoreLabel(ImageStoreKind.OCI_CACHE)).toBe('OCI 缓存'); + expect(imageAvailabilityLabel(ImageAvailabilityStatus.AVAILABLE)).toBe('可用'); + expect(imageAvailabilityLabel(ImageAvailabilityStatus.ERROR)).toBe('错误'); +}); diff --git a/src/lib/images.ts b/src/lib/images.ts new file mode 100644 index 0000000..91835aa --- /dev/null +++ b/src/lib/images.ts @@ -0,0 +1,33 @@ +import { + ImageAvailabilityStatus, + ImageStoreKind, + type ImagePlatform, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export function formatImageBytes(value: bigint | number | string): string { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const amount = bytes / 1024 ** index; + return `${amount >= 10 || Number.isInteger(amount) ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`; +} + +export function formatImagePlatform(platform?: Pick): string { + if (!platform) return '后端未提供'; + const value = [platform.os, platform.architecture, platform.variant].filter(Boolean).join('/'); + return value || '后端未提供'; +} + +export function imageStoreLabel(store: ImageStoreKind): string { + if (store === ImageStoreKind.DOCKER_DAEMON) return 'Docker daemon'; + if (store === ImageStoreKind.OCI_CACHE) return 'OCI 缓存'; + return '未指定'; +} + +export function imageAvailabilityLabel(status: ImageAvailabilityStatus): string { + if (status === ImageAvailabilityStatus.AVAILABLE) return '可用'; + if (status === ImageAvailabilityStatus.MISSING) return '缺失'; + if (status === ImageAvailabilityStatus.ERROR) return '错误'; + return '未知'; +} diff --git a/src/lib/project-dependency-preflight.test.ts b/src/lib/project-dependency-preflight.test.ts new file mode 100644 index 0000000..f1e8675 --- /dev/null +++ b/src/lib/project-dependency-preflight.test.ts @@ -0,0 +1,139 @@ +import { Code, ConnectError } from '@connectrpc/connect'; +import { describe, expect, test, vi } from 'vitest'; +import { + AgentSpec, + BoxliteDriverSpec, + DockerDriverSpec, + DriverSpec, + type InspectImageRequest, + type ListCapabilitySetsRequest, + ProjectSpec, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { checkProjectDependencies } from './project-dependency-preflight'; + +function dockerAgent(name: string, image: string) { + return new AgentSpec({ + name, + image, + driver: new DriverSpec({ docker: new DockerDriverSpec() }), + }); +} + +function clients(inspectImage = vi.fn(async (_request: InspectImageRequest): Promise => ({}))) { + return { + imageClient: { inspectImage }, + capabilityClient: { listCapabilitySets: vi.fn(async (_request: ListCapabilitySetsRequest): Promise<{ + capsets: Array<{ id: string; enabled: boolean }>; + }> => ({ capsets: [] })) }, + }; +} + +describe('project dependency image preflight', () => { + test('inspects each unique explicit Docker image once', async () => { + const inspectImage = vi.fn(async (_request: InspectImageRequest): Promise => ({})); + const spec = new ProjectSpec({ agents: [ + dockerAgent('alpha', 'registry.example/guest:latest'), + dockerAgent('beta', 'registry.example/guest:latest'), + dockerAgent('gamma', 'registry.example/other:v1'), + new AgentSpec({ + name: 'box', + image: 'registry.example/box:v1', + driver: new DriverSpec({ boxlite: new BoxliteDriverSpec() }), + }), + dockerAgent('default-image', ''), + ] }); + + await checkProjectDependencies({ spec, ...clients(inspectImage) }); + + expect(inspectImage).toHaveBeenCalledTimes(2); + expect(inspectImage.mock.calls.map(([request]) => request.imageRef).sort()).toEqual([ + 'registry.example/guest:latest', + 'registry.example/other:v1', + ]); + }); + + test('accepts the normalized driver name form', async () => { + const inspectImage = vi.fn(async (_request: InspectImageRequest): Promise => ({})); + const spec = new ProjectSpec({ agents: [new AgentSpec({ + name: 'named-docker', + image: 'registry.example/named:v1', + driver: new DriverSpec({ name: 'docker' }), + })] }); + + await checkProjectDependencies({ spec, ...clients(inspectImage) }); + + expect(inspectImage).toHaveBeenCalledTimes(1); + expect(inspectImage.mock.calls[0]![0].imageRef).toBe('registry.example/named:v1'); + }); + + test('blocks with the missing image and every affected agent', async () => { + const inspectImage = vi.fn(async (_request: InspectImageRequest): Promise => { + throw new ConnectError('No such image', Code.NotFound); + }); + const spec = new ProjectSpec({ agents: [ + dockerAgent('alpha', 'registry.example/missing:v1'), + dockerAgent('beta', 'registry.example/missing:v1'), + ] }); + + await expect(checkProjectDependencies({ spec, ...clients(inspectImage) })) + .rejects.toThrow(/registry\.example\/missing:v1.*alpha.*beta/s); + }); + + test('blocks when image availability cannot be checked', async () => { + const inspectImage = vi.fn(async (_request: InspectImageRequest): Promise => { + throw new ConnectError('docker unavailable', Code.Unavailable); + }); + const spec = new ProjectSpec({ agents: [dockerAgent('alpha', 'registry.example/guest:v1')] }); + + await expect(checkProjectDependencies({ spec, ...clients(inspectImage) })) + .rejects.toThrow(/无法检查.*registry\.example\/guest:v1.*docker unavailable/s); + }); +}); + +describe('project dependency capability-set preflight', () => { + test('does not request capability sets when no agent declares IDs', async () => { + const deps = clients(); + + const result = await checkProjectDependencies({ + spec: new ProjectSpec({ agents: [new AgentSpec({ name: 'alpha' })] }), + ...deps, + }); + + expect(deps.capabilityClient.listCapabilitySets).not.toHaveBeenCalled(); + expect(result.warnings).toEqual([]); + }); + + test('warns for missing and disabled sets without rejecting', async () => { + const deps = clients(); + deps.capabilityClient.listCapabilitySets.mockResolvedValue({ capsets: [ + { id: 'enabled', enabled: true }, + { id: 'disabled', enabled: false }, + { id: 'ENABLED', enabled: true }, + ] }); + const spec = new ProjectSpec({ agents: [ + new AgentSpec({ name: 'alpha', capsetIds: ['enabled', 'missing', 'disabled'] }), + new AgentSpec({ name: 'beta', capsetIds: ['missing', 'ENABLED'] }), + ] }); + + const result = await checkProjectDependencies({ spec, ...deps }); + + expect(deps.capabilityClient.listCapabilitySets).toHaveBeenCalledTimes(1); + expect(result.warnings).toEqual([ + 'Agent alpha 引用的能力集 missing 不存在', + 'Agent alpha 引用的能力集 disabled 未启用', + 'Agent beta 引用的能力集 missing 不存在', + ]); + }); + + test('turns capability service failure into one non-blocking warning', async () => { + const deps = clients(); + deps.capabilityClient.listCapabilitySets.mockRejectedValue(new Error('gateway offline')); + const spec = new ProjectSpec({ agents: [ + new AgentSpec({ name: 'alpha', capsetIds: ['core'] }), + ] }); + + const result = await checkProjectDependencies({ spec, ...deps }); + + expect(result.warnings).toEqual(['能力集检查失败,已继续执行:gateway offline']); + }); +}); diff --git a/src/lib/project-dependency-preflight.ts b/src/lib/project-dependency-preflight.ts new file mode 100644 index 0000000..ccfe4b3 --- /dev/null +++ b/src/lib/project-dependency-preflight.ts @@ -0,0 +1,84 @@ +import { Code, ConnectError } from '@connectrpc/connect'; +import { + InspectImageRequest, + ListCapabilitySetsRequest, + type ProjectSpec, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export interface ProjectDependencyImageClient { + inspectImage(request: InspectImageRequest): Promise; +} + +export interface ProjectDependencyCapabilityClient { + listCapabilitySets(request: ListCapabilitySetsRequest): Promise<{ capsets: Array<{ + id: string; + enabled: boolean; + }> }>; +} + +export interface ProjectDependencyPreflightOptions { + spec: Pick; + imageClient: ProjectDependencyImageClient; + capabilityClient: ProjectDependencyCapabilityClient; +} + +export interface ProjectDependencyPreflightResult { + warnings: string[]; +} + +function dockerImageAgents(spec: Pick): Map { + const images = new Map(); + for (const agent of spec.agents) { + const isDocker = agent.driver?.docker !== undefined || agent.driver?.name.trim().toLowerCase() === 'docker'; + const image = agent.image.trim(); + if (!isDocker || !image) continue; + const agents = images.get(image) ?? []; + agents.push(agent.name.trim() || '未命名 Agent'); + images.set(image, agents); + } + return images; +} + +export async function checkProjectDependencies( + options: ProjectDependencyPreflightOptions, +): Promise { + const failures = await Promise.all([...dockerImageAgents(options.spec)].map(async ([image, agents]) => { + try { + await options.imageClient.inspectImage(new InspectImageRequest({ imageRef: image })); + return ''; + } catch (error) { + const connected = ConnectError.from(error); + const agentList = agents.join('、'); + if (connected.code === Code.NotFound) { + return `镜像不存在:${image}(Agent:${agentList})。请先在镜像管理中拉取该镜像。`; + } + return `无法检查镜像 ${image}(Agent:${agentList}):${connected.message}`; + } + })); + const blocking = failures.filter(Boolean); + if (blocking.length > 0) throw new Error(blocking.join('\n')); + + const references = options.spec.agents.flatMap(agent => agent.capsetIds + .map(id => id.trim()) + .filter(Boolean) + .map(id => ({ agentName: agent.name.trim() || '未命名 Agent', id }))); + if (references.length === 0) return { warnings: [] }; + + try { + const response = await options.capabilityClient.listCapabilitySets(new ListCapabilitySetsRequest()); + const available = new Map(response.capsets.map(capset => [capset.id.trim(), capset])); + const warnings: string[] = []; + for (const reference of references) { + const capset = available.get(reference.id); + if (!capset) { + warnings.push(`Agent ${reference.agentName} 引用的能力集 ${reference.id} 不存在`); + } else if (!capset.enabled) { + warnings.push(`Agent ${reference.agentName} 引用的能力集 ${reference.id} 未启用`); + } + } + return { warnings }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { warnings: [`能力集检查失败,已继续执行:${message}`] }; + } +} diff --git a/src/lib/project-image-build.test.ts b/src/lib/project-image-build.test.ts new file mode 100644 index 0000000..866ca48 --- /dev/null +++ b/src/lib/project-image-build.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from 'vitest'; +import { ImageOperationStatus, ProjectSpec } from '../gen/agentcompose/v2/agentcompose_pb'; +import { yamlToSpec } from './yaml'; +import { + changedBuildAgentNames, + createProjectImageBuildPlans, + runProjectImageBuildPlans, +} from './project-image-build'; + +function project(yaml: string): ProjectSpec { + const parsed = yamlToSpec(yaml); + if (parsed.error) throw new Error(parsed.error); + return parsed.spec; +} + +describe('changedBuildAgentNames', () => { + const saved = project(`name: demo +agents: + reviewer: + image: reviewer:dev + build: + context: ./reviewer + dockerfile: Dockerfile.agent + target: runtime + args: + MODE: production + CACHE: enabled + platforms: [linux/amd64] + tags: [reviewer:latest] + no_cache: false + pull: false + removed: + image: removed:dev + build: { context: ./removed } +`); + + test('treats current build plans as changed when no saved project exists', () => { + expect(changedBuildAgentNames(undefined, saved)).toEqual(new Set(['reviewer', 'removed'])); + }); + + test('ignores unchanged builds and build argument insertion order', () => { + const current = project(`name: demo +agents: + reviewer: + image: reviewer:dev + build: + context: ./reviewer + dockerfile: Dockerfile.agent + target: runtime + args: + CACHE: enabled + MODE: production + platforms: [linux/amd64] + tags: [reviewer:latest] + no_cache: false + pull: false + removed: + image: removed:dev + build: { context: ./removed } +`); + + expect(changedBuildAgentNames(saved, current)).toEqual(new Set()); + }); + + test('detects modified and newly added builds but excludes removed builds', () => { + const current = project(`name: demo +agents: + reviewer: + image: reviewer:dev + build: + context: ./reviewer + dockerfile: Dockerfile.agent + target: runtime + args: + MODE: development + CACHE: enabled + platforms: [linux/amd64] + tags: [reviewer:latest] + writer: + image: writer:dev + build: { context: ./writer } + removed: + image: removed:dev +`); + + expect(changedBuildAgentNames(saved, current)).toEqual(new Set(['reviewer', 'writer'])); + }); +}); + +describe('createProjectImageBuildPlans', () => { + test('maps YAML build config using CLI-compatible defaults and tag merging', () => { + const plans = createProjectImageBuildPlans(project(`name: demo +agents: + reviewer: + image: reviewer:dev + build: + context: ./reviewer + dockerfile: Dockerfile.agent + target: runtime + args: + MODE: production + platforms: [linux/amd64] + tags: [reviewer:latest, reviewer:dev] + no_cache: true + pull: true + remote: + image: example/remote:latest +`), '/srv/demo/agent-compose.yml'); + + expect(plans).toHaveLength(1); + expect(plans[0].agentName).toBe('reviewer'); + expect(plans[0].request).toMatchObject({ + contextDir: '/srv/demo/reviewer', + dockerfile: 'Dockerfile.agent', + tags: ['reviewer:dev', 'reviewer:latest'], + buildArgs: { MODE: 'production' }, + target: 'runtime', + noCache: true, + pull: true, + }); + expect(plans[0].request?.platform).toMatchObject({ os: 'linux', architecture: 'amd64', variant: '' }); + expect(plans[0].error).toBe(''); + }); + + test('defaults context and Dockerfile and reports invalid build plans without hiding them', () => { + const plans = createProjectImageBuildPlans(project(`name: demo +agents: + missing-tag: + build: {} + multi-platform: + image: multi:dev + build: + platforms: [linux/amd64, linux/arm64] +`), '/srv/demo/agent-compose.yml'); + + expect(plans).toHaveLength(2); + expect(plans[0].request).toBeUndefined(); + expect(plans[0].error).toContain('image 或 build.tags'); + expect(plans[1].request).toBeUndefined(); + expect(plans[1].error).toContain('仅支持一个构建平台'); + }); + + test('requires a daemon compose source path before resolving relative contexts', () => { + const [plan] = createProjectImageBuildPlans(project(`name: demo +agents: + reviewer: + image: reviewer:dev + build: + context: ./reviewer +`), 'agent-compose.yml'); + + expect(plan.request).toBeUndefined(); + expect(plan.error).toContain('daemon 来源路径'); + }); +}); + +describe('runProjectImageBuildPlans', () => { + test('runs selected plans serially, applies transient flags, and publishes stream state', async () => { + const plans = createProjectImageBuildPlans(project(`name: demo +agents: + first: + image: first:dev + build: { context: ./first } + second: + image: second:dev + build: { context: ./second } +`), '/srv/demo/agent-compose.yml'); + const calls: string[] = []; + const updates: string[] = []; + const client = { + buildImage(request: { tags: string[]; noCache: boolean; pull: boolean }) { + calls.push(request.tags[0]); + expect(request.noCache).toBe(true); + expect(request.pull).toBe(true); + return (async function* () { + yield { status: ImageOperationStatus.RUNNING, stage: 'build', message: `building ${request.tags[0]}` }; + yield { status: ImageOperationStatus.SUCCEEDED, imageRef: request.tags[0] }; + })(); + }, + }; + + const results = await runProjectImageBuildPlans({ + plans, + selectedAgentNames: new Set(['first', 'second']), + client, + forceNoCache: true, + forcePull: true, + onUpdate: (result) => updates.push(`${result.agentName}:${result.status}:${result.stream.lines.length}`), + }); + + expect(calls).toEqual(['first:dev', 'second:dev']); + expect(results.map((result) => result.status)).toEqual(['succeeded', 'succeeded']); + expect(updates).toContain('first:building:1'); + expect(updates.at(-1)).toBe('second:succeeded:1'); + }); + + test('stops after the first failed build and marks later plans unexecuted', async () => { + const plans = createProjectImageBuildPlans(project(`name: demo +agents: + first: + image: first:dev + build: { context: ./first } + second: + image: second:dev + build: { context: ./second } +`), '/srv/demo/agent-compose.yml'); + const calls: string[] = []; + + await expect(runProjectImageBuildPlans({ + plans, + selectedAgentNames: new Set(['first', 'second']), + client: { + buildImage(request: { tags: string[] }) { + calls.push(request.tags[0]); + return (async function* () { + yield { status: ImageOperationStatus.FAILED, message: 'compile failed' }; + })(); + }, + }, + onUpdate: () => {}, + })).rejects.toMatchObject({ results: [ + { agentName: 'first', status: 'failed' }, + { agentName: 'second', status: 'unexecuted' }, + ] }); + expect(calls).toEqual(['first:dev']); + }); +}); diff --git a/src/lib/project-image-build.ts b/src/lib/project-image-build.ts new file mode 100644 index 0000000..096fe5f --- /dev/null +++ b/src/lib/project-image-build.ts @@ -0,0 +1,225 @@ +import { + BuildImageRequest, + ImagePlatform, + ImageStoreKind, + type BuildSpec, + type BuildImageEvent, + type ProjectSpec, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { consumeBuildImageEvents, type BuildStreamState } from '../modals/build-image'; + +export interface ProjectImageBuildPlan { + agentName: string; + imageRef: string; + contextDisplay: string; + dockerfile: string; + request?: BuildImageRequest; + error: string; +} + +export type ProjectImageBuildStatus = 'waiting' | 'building' | 'succeeded' | 'failed' | 'unexecuted'; + +export interface ProjectImageBuildRunResult { + agentName: string; + imageRef: string; + status: ProjectImageBuildStatus; + stream: BuildStreamState; + error: string; +} + +export interface ProjectImageBuildClient { + buildImage(request: BuildImageRequest): AsyncIterable>; +} + +export class ProjectImageBuildRunError extends Error { + results: ProjectImageBuildRunResult[]; + + constructor(message: string, results: ProjectImageBuildRunResult[]) { + super(message); + this.name = 'ProjectImageBuildRunError'; + this.results = results; + } +} + +const emptyStream = (): BuildStreamState => ({ lines: [], warnings: [], imageRef: '', resolvedRef: '' }); + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const raw of values) { + const value = raw.trim(); + if (!value || seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +function buildSignature(build: BuildSpec | undefined): string { + if (!build) return ''; + return JSON.stringify({ + context: build.context, + dockerfile: build.dockerfile, + target: build.target, + args: Object.entries(build.args).sort(([left], [right]) => left.localeCompare(right)), + platforms: [...build.platforms], + tags: [...build.tags], + noCache: build.noCache, + pull: build.pull, + }); +} + +export function changedBuildAgentNames(previous: ProjectSpec | undefined, current: ProjectSpec): Set { + const previousAgents = new Map(previous?.agents.map((agent) => [agent.name, agent]) ?? []); + return new Set(current.agents + .filter((agent) => agent.build && buildSignature(previousAgents.get(agent.name)?.build) !== buildSignature(agent.build)) + .map((agent) => agent.name)); +} + +function isAbsoluteDaemonPath(value: string): boolean { + return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value); +} + +function normalizePosixPath(value: string): string { + const absolute = value.startsWith('/'); + const parts: string[] = []; + for (const part of value.replaceAll('\\', '/').split('/')) { + if (!part || part === '.') continue; + if (part === '..') { + if (parts.length > 0) parts.pop(); + continue; + } + parts.push(part); + } + return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.'); +} + +function daemonDirname(composePath: string): string { + const normalized = composePath.replaceAll('\\', '/'); + const separator = normalized.lastIndexOf('/'); + return separator <= 0 ? (separator === 0 ? '/' : '') : normalized.slice(0, separator); +} + +function resolveContext(composePath: string, context: string): { value?: string; error?: string } { + const normalizedContext = context.trim() || '.'; + if (isAbsoluteDaemonPath(normalizedContext)) return { value: normalizePosixPath(normalizedContext) }; + if (!isAbsoluteDaemonPath(composePath.trim())) { + return { error: '无法解析 daemon 来源路径,请先保存项目后再构建' }; + } + return { value: normalizePosixPath(`${daemonDirname(composePath.trim())}/${normalizedContext}`) }; +} + +function parsePlatform(value: string): { platform?: ImagePlatform; error?: string } { + const normalized = value.trim(); + if (!normalized) return {}; + const parts = normalized.split('/'); + if (parts.length < 2 || parts.length > 3 || parts.some((part) => !part.trim())) { + return { error: `构建平台格式错误:${normalized},应为 os/arch[/variant]` }; + } + return { platform: new ImagePlatform({ os: parts[0], architecture: parts[1], variant: parts[2] || '' }) }; +} + +export function createProjectImageBuildPlans(spec: ProjectSpec, composePath: string): ProjectImageBuildPlan[] { + const plans: ProjectImageBuildPlan[] = []; + for (const agent of spec.agents) { + const build = agent.build; + if (!build) continue; + const tags = uniqueStrings([agent.image, ...build.tags]); + const base: ProjectImageBuildPlan = { + agentName: agent.name, + imageRef: tags[0] || '', + contextDisplay: build.context.trim() || '.', + dockerfile: build.dockerfile.trim() || 'Dockerfile', + error: '', + }; + if (tags.length === 0) { + plans.push({ ...base, error: `智能体 ${agent.name} 的构建配置需要 image 或 build.tags` }); + continue; + } + if (build.platforms.length > 1) { + plans.push({ ...base, error: `智能体 ${agent.name} 当前仅支持一个构建平台` }); + continue; + } + const context = resolveContext(composePath, build.context); + if (context.error || !context.value) { + plans.push({ ...base, error: context.error || '无法解析构建目录' }); + continue; + } + const parsedPlatform = parsePlatform(build.platforms[0] || ''); + if (parsedPlatform.error) { + plans.push({ ...base, error: parsedPlatform.error }); + continue; + } + plans.push({ + ...base, + request: new BuildImageRequest({ + contextDir: context.value, + dockerfile: base.dockerfile, + tags, + buildArgs: { ...build.args }, + target: build.target.trim(), + store: ImageStoreKind.DOCKER_DAEMON, + platform: parsedPlatform.platform, + noCache: build.noCache, + pull: build.pull, + }), + }); + } + return plans; +} + +function cloneStream(stream: BuildStreamState): BuildStreamState { + return { + ...stream, + lines: stream.lines.map((line) => ({ ...line })), + warnings: [...stream.warnings], + }; +} + +export async function runProjectImageBuildPlans(options: { + plans: ProjectImageBuildPlan[]; + selectedAgentNames: Set; + client: ProjectImageBuildClient; + forceNoCache?: boolean; + forcePull?: boolean; + onUpdate: (result: ProjectImageBuildRunResult, results: ProjectImageBuildRunResult[]) => void; +}): Promise { + const selected = options.plans.filter((plan) => options.selectedAgentNames.has(plan.agentName)); + const invalid = selected.find((plan) => !plan.request || plan.error); + if (invalid) throw new ProjectImageBuildRunError(invalid.error || `智能体 ${invalid.agentName} 的构建配置无效`, []); + + const results = selected.map((plan) => ({ + agentName: plan.agentName, + imageRef: plan.imageRef, + status: 'waiting', + stream: emptyStream(), + error: '', + })); + + for (let index = 0; index < selected.length; index++) { + const plan = selected[index]; + const result = results[index]; + result.status = 'building'; + options.onUpdate({ ...result, stream: cloneStream(result.stream) }, results); + const request = new BuildImageRequest({ + ...plan.request, + noCache: Boolean(plan.request?.noCache || options.forceNoCache), + pull: Boolean(plan.request?.pull || options.forcePull), + }); + try { + result.stream = await consumeBuildImageEvents(options.client.buildImage(request), (stream) => { + result.stream = cloneStream(stream); + options.onUpdate({ ...result, stream: cloneStream(result.stream) }, results); + }); + result.status = 'succeeded'; + options.onUpdate({ ...result, stream: cloneStream(result.stream) }, results); + } catch (cause) { + result.status = 'failed'; + result.error = cause instanceof Error ? cause.message : String(cause); + for (let later = index + 1; later < results.length; later++) results[later].status = 'unexecuted'; + options.onUpdate({ ...result, stream: cloneStream(result.stream) }, results); + throw new ProjectImageBuildRunError(result.error, results.map((item) => ({ ...item, stream: cloneStream(item.stream) }))); + } + } + return results.map((result) => ({ ...result, stream: cloneStream(result.stream) })); +} diff --git a/src/lib/projects.test.js b/src/lib/projects.test.js new file mode 100644 index 0000000..b1baffb --- /dev/null +++ b/src/lib/projects.test.js @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test'; +import { deduplicateProjectEntries } from './projects'; + +function project(projectId, name = 'tech-radar') { + return { + summary: { projectId, name, sourcePath: '/projects/tech-radar/agent-compose.yml' }, + source: { composePath: '/projects/tech-radar/agent-compose.yml', projectDir: '' }, + yamlContent: '', + dirty: false, + }; +} + +describe('deduplicateProjectEntries', () => { + test('treats legacy sha256-prefixed and current IDs as the same project', () => { + const hash = '31ccba9d49e51ff2093b2a9ec7f69d6676f72a1e952569cf99c567007d19581f'; + + const result = deduplicateProjectEntries([ + project(`sha256:${hash}`), + project(hash), + ]); + + expect(result).toHaveLength(1); + expect(result[0].summary.projectId).toBe(hash); + }); + + test('keeps a legacy project when no current ID exists', () => { + const legacy = project('sha256:legacy'); + + expect(deduplicateProjectEntries([legacy])).toEqual([legacy]); + }); + + test('refreshes an existing project with the latest summary and spec hash', () => { + const stale = project('project-1'); + stale.summary.specHash = ''; + const refreshed = project('project-1'); + refreshed.summary.specHash = 'sha256:current'; + refreshed.summary.currentRevision = 5n; + + expect(deduplicateProjectEntries([stale, refreshed])).toEqual([refreshed]); + }); + + test('does not merge unrelated projects with the same name', () => { + const result = deduplicateProjectEntries([ + project('first'), + project('second'), + ]); + + expect(result).toHaveLength(2); + }); +}); diff --git a/src/lib/projects.ts b/src/lib/projects.ts new file mode 100644 index 0000000..48afd17 --- /dev/null +++ b/src/lib/projects.ts @@ -0,0 +1,27 @@ +import type { ProjectEntry } from './types'; + +export function canonicalProjectId(projectId: string): string { + return projectId.startsWith('sha256:') + ? projectId.slice('sha256:'.length) + : projectId; +} + +export function isSameProjectId(left: string, right: string): boolean { + return !!left && !!right && canonicalProjectId(left) === canonicalProjectId(right); +} + +export function deduplicateProjectEntries(projects: ProjectEntry[]): ProjectEntry[] { + const byCanonicalId = new Map(); + + for (const project of projects) { + const key = canonicalProjectId(project.summary.projectId); + const existing = byCanonicalId.get(key); + const existingIsLegacy = existing?.summary.projectId.startsWith('sha256:') ?? false; + const incomingIsLegacy = project.summary.projectId.startsWith('sha256:'); + if (!existing || (existingIsLegacy && !incomingIsLegacy) || existingIsLegacy === incomingIsLegacy) { + byCanonicalId.set(key, project); + } + } + + return Array.from(byCanonicalId.values()); +} diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts new file mode 100644 index 0000000..bf37712 --- /dev/null +++ b/src/lib/rpc.ts @@ -0,0 +1,50 @@ +import { createConnectTransport } from '@connectrpc/connect-web'; +import { createClient } from '@connectrpc/connect'; +import type { Transport } from '@connectrpc/connect'; + +// Generated service descriptors +import { + CacheService, + CapabilityService, + DashboardService, + ExecService, + ImageService, + ProjectService, + RunService, + SandboxService, + SettingsService, + VolumeService, +} from '../gen/agentcompose/v2/agentcompose_connect'; + +const RPC_TIMEOUT_MS = 120_000; // 2 min — covers slow Docker pulls, gives user feedback sooner + +const transport: Transport = createConnectTransport({ + baseUrl: window.location.origin, + // Vite proxy forwards agentcompose v2 RPCs to the daemon at 127.0.0.1:7410. + fetch: (input, init) => { + const controller = new AbortController(); + const sourceSignal = init?.signal; + if (sourceSignal) { + if (sourceSignal.aborted) { + // Signal already aborted — propagate immediately + controller.abort(sourceSignal.reason); + } else { + sourceSignal.addEventListener('abort', () => controller.abort(sourceSignal.reason), { once: true }); + } + } + const timeout = setTimeout(() => controller.abort(new DOMException('RPC timeout', 'TimeoutError')), RPC_TIMEOUT_MS); + return fetch(input, { ...init, signal: controller.signal }) + .finally(() => clearTimeout(timeout)); + }, +}); + +export const projectService = createClient(ProjectService, transport); +export const runService = createClient(RunService, transport); +export const execService = createClient(ExecService, transport); +export const sandboxService = createClient(SandboxService, transport); +export const imageService = createClient(ImageService, transport); +export const cacheService = createClient(CacheService, transport); +export const volumeService = createClient(VolumeService, transport); +export const dashboardService = createClient(DashboardService, transport); +export const settingsService = createClient(SettingsService, transport); +export const capabilityService = createClient(CapabilityService, transport); diff --git a/src/lib/run-confirmed-evidence.test.ts b/src/lib/run-confirmed-evidence.test.ts new file mode 100644 index 0000000..ed2370c --- /dev/null +++ b/src/lib/run-confirmed-evidence.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'vitest'; +import { + RunEvent, + RunEventKind, + SandboxHistoryCell, + SchedulerEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { + buildConfirmedEvidenceTimeline, + confirmedCell, + confirmedSandboxRunEvents, + resultCellId, +} from './run-confirmed-evidence'; + +describe('confirmed Cell evidence', () => { + const cells = [ + new SandboxHistoryCell({ id: 'other', output: 'wrong' }), + new SandboxHistoryCell({ id: 'cell-1', source: 'prompt', stdout: 'out', stderr: 'warn', output: 'answer', exitCode: 1 }), + ]; + + test('extracts and matches only the exact result Cell ID', () => { + expect(resultCellId('{"cellId":"cell-1"}')).toBe('cell-1'); + expect(confirmedCell(cells, '{"cellId":"cell-1"}')?.output).toBe('answer'); + }); + + test('does not guess a Cell for malformed or missing result metadata', () => { + expect(resultCellId('{bad')).toBe(''); + expect(confirmedCell(cells, '{}')).toBeUndefined(); + expect(confirmedCell(cells, '{"cellId":"missing"}')).toBeUndefined(); + }); +}); + +test('keeps only Sandbox Run events with the exact Project Run ID', () => { + const events = [ + new RunEvent({ id: 'current', runId: 'run-1' }), + new RunEvent({ id: 'other', runId: 'run-10' }), + ]; + expect(confirmedSandboxRunEvents(events, 'run-1').map(event => event.id)).toEqual(['current']); +}); + +test('does not mark a successful Cell as error only because stderr contains warnings', () => { + const entries = buildConfirmedEvidenceTimeline({ + schedulerEvents: [], + cell: new SandboxHistoryCell({ id: 'cell-ok', stderr: 'metadata fallback warning', output: 'answer', success: true, exitCode: 0 }), + sandboxRunEvents: [], + existingRunEventIds: new Set(), + logsPath: '', artifactsDir: '', completedAt: '', updatedAt: '', startedAt: '', + }); + + expect(entries[0]).toMatchObject({ id: 'cell:cell-ok', level: 'warning' }); + expect(entries[0].filterTags).toEqual(expect.arrayContaining(['artifact', 'problem'])); +}); + +test('projects confirmed evidence, removes duplicate Run events, and tags artifacts', () => { + const entries = buildConfirmedEvidenceTimeline({ + schedulerEvents: [new SchedulerEvent({ + id: 'scheduler-start', type: 'loader.run.started', message: 'started', runId: 'loader-1', createdAt: { seconds: 1n }, + })], + cell: new SandboxHistoryCell({ + id: 'cell-1', source: 'prompt', stdout: 'stdout', stderr: 'stderr', output: 'answer', exitCode: 1, + stopReason: 'failed', createdAt: { seconds: 2n }, + }), + sandboxRunEvents: [ + new RunEvent({ id: 'already-loaded', runId: 'run-1', text: 'duplicate' }), + new RunEvent({ id: 'sandbox-status', runId: 'run-1', kind: RunEventKind.STATUS, text: 'done', success: true, createdAt: { seconds: 3n } }), + ], + existingRunEventIds: new Set(['already-loaded']), + logsPath: '/logs/output.txt', + artifactsDir: '/artifacts/cell-1', + completedAt: '1970-01-01T00:00:04.000Z', + updatedAt: '', + startedAt: '', + }); + + expect(entries.map(entry => entry.id)).toEqual(expect.arrayContaining([ + 'scheduler:scheduler-start', 'cell:cell-1', 'sandbox-run:sandbox-status', 'run:logs-path', 'run:artifacts-dir', + ])); + expect(entries.some(entry => entry.content.includes('duplicate'))).toBe(false); + expect(entries.find(entry => entry.id === 'cell:cell-1')).toMatchObject({ + source: 'Agent Cell', level: 'error', filterTags: expect.arrayContaining(['message', 'artifact', 'problem']), + }); + expect(entries.find(entry => entry.id === 'run:logs-path')).toMatchObject({ + timestamp: '1970-01-01T00:00:04.000Z', timestampInferred: true, timestampBasis: 'run-end', filterTags: ['artifact'], + }); + expect(entries.map(entry => entry.sortTime)).toEqual([...entries.map(entry => entry.sortTime)].sort((a, b) => a - b)); +}); + +test('deduplicates the real Scheduler child Run shape by semantic ownership', () => { + const prompt = 'Reply with exactly yaml-script-trigger-ok'; + const answer = 'model warning and final answer yaml-script-trigger-ok'; + const resultJson = '{"cellId":"cell-1","sandboxId":"sandbox-1","exitCode":0,"success":true}'; + const entries = buildConfirmedEvidenceTimeline({ + schedulerEvents: [ + new SchedulerEvent({ id: 'start', type: 'loader.run.started', message: 'loader run started', payloadJson: '{"source":"interval:40000"}' }), + new SchedulerEvent({ id: 'log', type: 'loader.log', message: 'yaml scheduler script interval executed', payloadJson: '{"source":"full-yaml-e2e"}' }), + new SchedulerEvent({ id: 'agent', type: 'loader.agent.completed', message: answer, payloadJson: JSON.stringify({ text: answer, output: answer, finalText: answer, sandboxId: 'sandbox-1', cellId: 'cell-1', success: true, exitCode: 0 }) }), + new SchedulerEvent({ id: 'done', type: 'loader.run.completed', message: 'loader run completed', payloadJson: JSON.stringify({ resultJson }) }), + ], + cell: new SandboxHistoryCell({ id: 'cell-1', source: prompt, stderr: answer, output: answer, success: true, exitCode: 0, stopReason: 'completed' }), + sandboxRunEvents: [], + existingRunEventIds: new Set(), + existingRunEventContents: new Set([prompt, answer, `${resultJson}\n成功 · 退出码 0`]), + output: answer, + resultJson, + logsPath: '', artifactsDir: '', completedAt: '', updatedAt: '', startedAt: '', + }); + + const content = entries.map(entry => entry.content).join('\n'); + expect(content).toContain('yaml scheduler script interval executed'); + expect(content).toContain('loader run started'); + expect(content).toContain('loader run completed'); + expect(content).not.toContain(prompt); + expect(content).not.toContain(answer); + expect(content).not.toContain('finalText'); + expect(content).not.toContain('resultJson'); + expect(entries.find(entry => entry.id === 'scheduler:agent')?.content).toContain('sandbox-1'); + expect(entries.some(entry => entry.id === 'cell:cell-1')).toBe(false); +}); diff --git a/src/lib/run-confirmed-evidence.ts b/src/lib/run-confirmed-evidence.ts new file mode 100644 index 0000000..5cf7698 --- /dev/null +++ b/src/lib/run-confirmed-evidence.ts @@ -0,0 +1,194 @@ +import { + RunEventKind, + type RunEvent, + type SandboxHistoryCell, + type SchedulerEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { mapRunEventsToTranscript } from './agent-run-transcript'; +import type { RuntimeTimelineEntry, RuntimeTimelineFilterTag } from './runtime-timeline'; + +export function resultCellId(resultJson: string): string { + try { + const value = JSON.parse(resultJson || '{}'); + return typeof value?.cellId === 'string' ? value.cellId.trim() : ''; + } catch { + return ''; + } +} + +export function confirmedCell(cells: readonly SandboxHistoryCell[], resultJson: string): SandboxHistoryCell | undefined { + const cellId = resultCellId(resultJson); + return cellId ? cells.find(cell => cell.id === cellId) : undefined; +} + +export function confirmedSandboxRunEvents(events: readonly RunEvent[], runId: string): RunEvent[] { + return events.filter(event => event.runId === runId); +} + +function timestamp(value?: { seconds: bigint; nanos: number }): string { + if (!value) return ''; + return new Date(Number(value.seconds) * 1000 + value.nanos / 1_000_000).toISOString(); +} + +function sortTime(value: string): number { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function uniqueTags(tags: RuntimeTimelineFilterTag[]): RuntimeTimelineFilterTag[] { + return [...new Set(tags)]; +} + +function ownedByExisting(content: string, existing?: ReadonlySet): boolean { + const value = content.trim(); + if (!value) return true; + return [...(existing ?? [])].some(item => item.trim() === value || item.includes(value)); +} + +function schedulerContent(event: SchedulerEvent, existing?: ReadonlySet): string { + if (event.type === 'loader.run.completed') return event.message || 'loader run completed'; + if (event.type !== 'loader.agent.completed') { + return [event.message, event.payloadJson].filter(value => value && !ownedByExisting(value, existing)).join('\n') || event.type || '调度事件'; + } + let payload: Record = {}; + try { payload = JSON.parse(event.payloadJson || '{}'); } catch {} + const metadata = Object.fromEntries(['sandboxId', 'cellId', 'agent', 'stopReason', 'success', 'exitCode'] + .filter(key => payload[key] !== undefined) + .map(key => [key, payload[key]])); + return [ + ownedByExisting(event.message, existing) ? 'Agent 调用完成' : event.message, + Object.keys(metadata).length ? JSON.stringify(metadata) : '', + ].filter(Boolean).join('\n'); +} + +export interface ConfirmedEvidenceTimelineInput { + schedulerEvents: readonly SchedulerEvent[]; + cell?: SandboxHistoryCell; + sandboxRunEvents: readonly RunEvent[]; + existingRunEventIds: ReadonlySet; + logsPath: string; + artifactsDir: string; + output?: string; + resultJson?: string; + existingRunEventContents?: ReadonlySet; + completedAt: string; + updatedAt: string; + startedAt: string; +} + +export function buildConfirmedEvidenceTimeline(input: ConfirmedEvidenceTimelineInput): RuntimeTimelineEntry[] { + const entries: RuntimeTimelineEntry[] = []; + let sequence = 10_000; + const add = (entry: Omit) => { + entries.push({ ...entry, sequence: sequence++, sortTime: sortTime(entry.timestamp) }); + }; + + for (const event of input.schedulerEvents) { + const createdAt = timestamp(event.createdAt); + const level = /error|fatal|fail/i.test(`${event.level} ${event.type}`) ? 'error' : event.level === 'warning' ? 'warning' : 'info'; + add({ + id: `scheduler:${event.id || sequence}`, + timestamp: createdAt, + kind: 'scheduler', + source: '调度器', + level, + content: schedulerContent(event, input.existingRunEventContents), + timestampInferred: false, + filterTags: uniqueTags(['run', ...(level === 'error' || level === 'warning' ? ['problem' as const] : [])]), + }); + } + + if (input.cell) { + const cell = input.cell; + const createdAt = timestamp(cell.createdAt); + const grouped = new Map(); + for (const [label, content] of [['输入', cell.source], ['stdout', cell.stdout], ['stderr', cell.stderr], ['输出', cell.output]] as const) { + if (!content || ownedByExisting(content, input.existingRunEventContents)) continue; + grouped.set(content, [...(grouped.get(content) ?? []), label]); + } + const parts = [...grouped].map(([content, labels]) => `${labels.join(' / ')}\n${content}`); + if (!cell.running && (!cell.success || cell.exitCode !== 0 || (cell.stopReason && cell.stopReason !== 'completed'))) { + parts.push(`退出码 ${cell.exitCode}${cell.stopReason ? ` · ${cell.stopReason}` : ''}`); + } + if (parts.length > 0) { + const hasArtifact = Boolean(cell.stdout || cell.stderr || cell.output); + const hasProblem = Boolean(!cell.running && (!cell.success || cell.exitCode !== 0)); + const hasWarning = Boolean(cell.stderr && !hasProblem); + add({ + id: `cell:${cell.id}`, + timestamp: createdAt, + kind: 'output', + source: 'Agent Cell', + level: hasProblem ? 'error' : hasWarning ? 'warning' : 'info', + content: parts.join('\n\n'), + timestampInferred: false, + filterTags: uniqueTags([ + ...(cell.source ? ['message' as const] : []), + ...(hasArtifact ? ['artifact' as const] : []), + ...(hasProblem || hasWarning ? ['problem' as const] : []), + ]), + }); + } + } + + const sandboxEvents = input.sandboxRunEvents.filter(event => !input.existingRunEventIds.has(event.id)); + const sandboxEventsById = new Map(sandboxEvents.map(event => [event.id || `run-event-${event.seq}`, event])); + for (const transcript of mapRunEventsToTranscript(sandboxEvents)) { + const original = sandboxEventsById.get(transcript.id); + const problem = transcript.kind === 'diagnostic'; + add({ + id: `sandbox-run:${transcript.id}`, + timestamp: transcript.timestamp, + kind: problem ? 'error' : 'process', + source: 'Sandbox Run', + level: problem ? 'error' : 'info', + content: transcript.content, + timestampInferred: !transcript.timestamp, + ...(!transcript.timestamp ? { timestampBasis: 'run-start' as const } : {}), + filterTags: uniqueTags([ + original?.kind === RunEventKind.USER_MESSAGE || original?.kind === RunEventKind.AGENT_MESSAGE ? 'message' : 'run', + ...(original?.kind === RunEventKind.AGENT_MESSAGE ? ['artifact' as const] : []), + ...(problem ? ['problem' as const] : []), + ]), + }); + } + + const terminalTimestamp = input.completedAt || input.updatedAt || input.startedAt; + const terminalBasis = input.completedAt ? 'run-end' as const : input.updatedAt ? 'run-updated' as const : 'run-start' as const; + for (const [id, label, content, tags] of [ + ['output', 'Agent 输出', input.output || '', ['message', 'artifact'] as RuntimeTimelineFilterTag[]], + ['result-json', 'Result JSON', input.resultJson || '', ['artifact'] as RuntimeTimelineFilterTag[]], + ] as const) { + if (!content || ownedByExisting(content, input.existingRunEventContents)) continue; + add({ + id: `run:${id}`, + timestamp: terminalTimestamp, + kind: id === 'output' ? 'output' : 'result', + source: label, + level: 'info', + content, + timestampInferred: true, + timestampBasis: terminalBasis, + filterTags: [...tags], + }); + } + for (const [id, label, path] of [ + ['logs-path', '运行日志位置', input.logsPath], + ['artifacts-dir', 'Artifacts 位置', input.artifactsDir], + ] as const) { + if (!path) continue; + add({ + id: `run:${id}`, + timestamp: terminalTimestamp, + kind: 'result', + source: label, + level: 'info', + content: path, + timestampInferred: true, + timestampBasis: terminalBasis, + filterTags: ['artifact'], + }); + } + + return entries.sort((left, right) => left.sortTime - right.sortTime || left.sequence - right.sequence || left.id.localeCompare(right.id)); +} diff --git a/src/lib/run-controls.test.js b/src/lib/run-controls.test.js new file mode 100644 index 0000000..edff491 --- /dev/null +++ b/src/lib/run-controls.test.js @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { RunSandboxCleanupPolicy } from '../gen/agentcompose/v2/agentcompose_pb'; +import { + buildFollowRunLogsRequest, + buildRunAgentRequest, + buildStopRunRequest, +} from './run-controls'; + +describe('buildRunAgentRequest', () => { + test('builds a command run with temporary overrides', () => { + const request = buildRunAgentRequest({ + projectId: 'project-1', agentName: '审查员', mode: 'command', input: 'bun test', + sandboxId: 'sandbox-1', driver: 'docker', cleanupPolicy: RunSandboxCleanupPolicy.KEEP_RUNNING, + jupyterEnabled: true, jupyterExpose: true, + }); + expect(request.prompt).toBe(''); + expect(request.command).toBe('bun test'); + expect(request.sandboxId).toBe('sandbox-1'); + expect(request.driver).toBe('docker'); + expect(request.cleanupPolicy).toBe(RunSandboxCleanupPolicy.KEEP_RUNNING); + }); + + test('rejects empty run input', () => { + expect(() => buildRunAgentRequest({ + projectId: 'project-1', agentName: '审查员', mode: 'prompt', input: ' ', + sandboxId: '', driver: '', cleanupPolicy: RunSandboxCleanupPolicy.UNSPECIFIED, + jupyterEnabled: false, jupyterExpose: false, + })).toThrow('请输入本次运行内容'); + }); + + test('keeps prompt and command mutually exclusive', () => { + const request = buildRunAgentRequest({ + projectId: 'project-1', agentName: ' writer ', mode: 'prompt', input: ' write it ', + sandboxId: '', driver: '', cleanupPolicy: RunSandboxCleanupPolicy.UNSPECIFIED, + jupyterEnabled: false, jupyterExpose: false, + }); + expect(request).toMatchObject({ agentName: 'writer', prompt: 'write it', command: '', sandboxId: '', driver: '' }); + }); +}); + +test('builds follow and stop requests', () => { + expect(buildFollowRunLogsRequest({ projectId: 'p', runId: 'r', tailLines: 100, follow: true })) + .toMatchObject({ projectId: 'p', runId: 'r', tailLines: 100, follow: true }); + expect(buildStopRunRequest('r')).toMatchObject({ runId: 'r', reason: '用户从 Web 请求停止' }); +}); diff --git a/src/lib/run-controls.ts b/src/lib/run-controls.ts new file mode 100644 index 0000000..7eb50f0 --- /dev/null +++ b/src/lib/run-controls.ts @@ -0,0 +1,53 @@ +import { + FollowRunLogsRequest, + RunAgentRequest, + RunJupyterSpec, + RunSandboxCleanupPolicy, + RunSource, + StopRunRequest, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export type RunInputMode = 'prompt' | 'command'; + +export interface RunRequestInput { + projectId: string; + agentName: string; + mode: RunInputMode; + input: string; + sandboxId: string; + driver: string; + cleanupPolicy: RunSandboxCleanupPolicy; + jupyterEnabled: boolean; + jupyterExpose: boolean; +} + +export function buildRunAgentRequest(input: RunRequestInput): RunAgentRequest { + const value = input.input.trim(); + if (!value) throw new Error('请输入本次运行内容'); + return new RunAgentRequest({ + projectId: input.projectId, + agentName: input.agentName.trim(), + source: RunSource.MANUAL, + prompt: input.mode === 'prompt' ? value : '', + command: input.mode === 'command' ? value : '', + sandboxId: input.sandboxId.trim(), + driver: input.driver.trim(), + cleanupPolicy: input.cleanupPolicy, + jupyter: input.jupyterEnabled + ? new RunJupyterSpec({ enabled: true, expose: input.jupyterExpose }) + : undefined, + }); +} + +export function buildFollowRunLogsRequest(input: { + projectId: string; + runId: string; + tailLines: number; + follow: boolean; +}): FollowRunLogsRequest { + return new FollowRunLogsRequest(input); +} + +export function buildStopRunRequest(runId: string): StopRunRequest { + return new StopRunRequest({ runId, reason: '用户从 Web 请求停止' }); +} diff --git a/src/lib/run-history.test.js b/src/lib/run-history.test.js new file mode 100644 index 0000000..dc95273 --- /dev/null +++ b/src/lib/run-history.test.js @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test'; +import { RunSummary } from '../gen/agentcompose/v2/agentcompose_pb'; +import { buildRunDateRange, consumeRunWindow } from './run-history'; + +const runs = (...ids) => ids.map(runId => new RunSummary({ runId })); + +describe('run history windows', () => { + test('uses one lookahead record so exactly 50 has no more and 51 has more', () => { + const exact = consumeRunWindow(runs(...Array.from({ length: 50 }, (_, i) => `run-${i}`)), 50); + expect(exact).toMatchObject({ hasMore: false, serverOffset: 50 }); + expect(exact.runs).toHaveLength(50); + const lookahead = consumeRunWindow(runs(...Array.from({ length: 51 }, (_, i) => `run-${i}`)), 50); + expect(lookahead).toMatchObject({ hasMore: true, serverOffset: 50 }); + expect(lookahead.runs).toHaveLength(50); + }); + + test('deduplicates by run id while preserving first server position and latest value', () => { + const duplicate = new RunSummary({ runId: 'b', agentName: 'latest' }); + const window = consumeRunWindow([new RunSummary({ runId: 'a' }), new RunSummary({ runId: 'b', agentName: 'old' }), duplicate, new RunSummary({ runId: 'c' })], 3); + expect(window.runs.map(run => [run.runId, run.agentName])).toEqual([['a', ''], ['b', 'latest']]); + expect(window.serverOffset).toBe(3); + expect(window.hasMore).toBe(true); + }); + + test('builds UTC query bounds from local calendar midnights', () => { + const range = buildRunDateRange('2026-07-01', '2026-07-15'); + expect(range.startedFrom).toBe(new Date(2026, 6, 1, 0, 0, 0, 0).toISOString()); + expect(range.startedTo).toBe(new Date(new Date(2026, 6, 16, 0, 0, 0, 0).getTime() - 1).toISOString()); + expect(new Date(range.startedTo).getTime()).toBe(new Date(2026, 6, 16, 0, 0, 0, 0).getTime() - 1); + }); +}); diff --git a/src/lib/run-history.ts b/src/lib/run-history.ts new file mode 100644 index 0000000..9215aee --- /dev/null +++ b/src/lib/run-history.ts @@ -0,0 +1,29 @@ +import type { RunSummary } from '../gen/agentcompose/v2/agentcompose_pb'; + +function localDate(value: string, nextDay = false): Date | undefined { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return undefined; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + (nextDay ? 1 : 0), 0, 0, 0, 0); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +export function buildRunDateRange(from: string, to: string): { startedFrom: string; startedTo: string } { + const lower = localDate(from); + const nextUpper = localDate(to, true); + return { + startedFrom: lower?.toISOString() ?? '', + startedTo: nextUpper ? new Date(nextUpper.getTime() - 1).toISOString() : '', + }; +} + +export function consumeRunWindow(serverRuns: RunSummary[], target: number): { runs: RunSummary[]; serverOffset: number; hasMore: boolean } { + const consumed = serverRuns.slice(0, target); + const positions = new Map(); + const runs: RunSummary[] = []; + for (const run of consumed) { + const position = positions.get(run.runId); + if (position === undefined) { positions.set(run.runId, runs.length); runs.push(run); } + else runs[position] = run; + } + return { runs, serverOffset: consumed.length, hasMore: serverRuns.length > target }; +} diff --git a/src/lib/run-log-window.test.js b/src/lib/run-log-window.test.js new file mode 100644 index 0000000..8b533f4 --- /dev/null +++ b/src/lib/run-log-window.test.js @@ -0,0 +1,55 @@ +import { expect, test } from 'bun:test'; +import { + appendLiveRunLogChunk, + beginRunLogLoad, + initialRunLogWindow, + nextRunLogScope, + replaceRunLogWindow, +} from './run-log-window'; + +const chunk = (data, offset, sequence = 0) => ({ data, offset, sequence, createdAt: '2026-07-15T01:00:00Z' }); +const stateWith = (chunks) => ({ ...initialRunLogWindow(), chunks: chunks.map((item, index) => chunk(item.data, item.offset, index)) }); + +test('advances from 100 to 500 to all and only all is complete', () => { + expect(initialRunLogWindow()).toMatchObject({ scope: 'tail-100', tailLines: 100, fullyLoaded: false }); + expect(nextRunLogScope('tail-100')).toBe('tail-500'); + expect(nextRunLogScope('tail-500')).toBe('all'); + expect(nextRunLogScope('all')).toBe('all'); + expect(replaceRunLogWindow(initialRunLogWindow(), 'tail-500', [])).toMatchObject({ tailLines: 500, fullyLoaded: false }); + expect(replaceRunLogWindow(initialRunLogWindow(), 'all', [])).toMatchObject({ tailLines: 0, fullyLoaded: true }); +}); + +test('replaces overlapping windows instead of appending duplicates', () => { + const current = stateWith([{ data: 'new', offset: 20n }]); + const replaced = replaceRunLogWindow(current, 'tail-500', [ + { data: 'old', offset: 10n }, + { data: 'new', offset: 20n }, + ]); + expect(replaced.chunks.map((chunk) => chunk.data)).toEqual(['old', 'new']); + expect(replaced.fullyLoaded).toBe(false); +}); + +test('keeps live chunks that arrive while a wider window is loading', () => { + const loading = beginRunLogLoad(stateWith([{ data: 'base', offset: 10n }]), 'tail-500'); + const withLive = appendLiveRunLogChunk(loading, { data: 'live', offset: 30n }); + const completed = replaceRunLogWindow(withLive, 'tail-500', [ + { data: 'older', offset: 0n }, + { data: 'base', offset: 10n }, + ]); + expect(completed.chunks.map((chunk) => chunk.data)).toEqual(['older', 'base', 'live']); +}); + +test('beginning a replacement load preserves pending live chunks', () => { + const loading = beginRunLogLoad(stateWith([]), 'tail-500'); + const withLive = appendLiveRunLogChunk(loading, { data: 'live', offset: 30n }); + const restarted = beginRunLogLoad(withLive, 'all'); + expect(restarted.pendingLiveChunks.map((item) => item.data)).toEqual(['live']); +}); + +test('keeps identical text at distinct offsets', () => { + const replaced = replaceRunLogWindow(initialRunLogWindow(), 'all', [ + { data: 'same', offset: 10n }, + { data: 'same', offset: 20n }, + ]); + expect(replaced.chunks.map((item) => [item.data, item.offset])).toEqual([['same', 10n], ['same', 20n]]); +}); diff --git a/src/lib/run-log-window.ts b/src/lib/run-log-window.ts new file mode 100644 index 0000000..ebb8ed7 --- /dev/null +++ b/src/lib/run-log-window.ts @@ -0,0 +1,96 @@ +export type RunLogScope = 'tail-100' | 'tail-500' | 'all'; + +export const RUN_LOG_TAIL_LINES = { 'tail-100': 100, 'tail-500': 500, all: 0 } as const; + +export interface RunLogWindowChunk { + data: string; + createdAt: string; + offset: bigint; + sequence: number; +} + +export interface RunLogWindowState { + scope: RunLogScope; + tailLines: number; + fullyLoaded: boolean; + chunks: RunLogWindowChunk[]; + pendingLiveChunks: RunLogWindowChunk[]; + loadingScope?: RunLogScope; +} + +type IncomingRunLogChunk = Omit & + Partial>; + +export function initialRunLogWindow(): RunLogWindowState { + return { + scope: 'tail-100', + tailLines: RUN_LOG_TAIL_LINES['tail-100'], + fullyLoaded: false, + chunks: [], + pendingLiveChunks: [], + }; +} + +export function nextRunLogScope(scope: RunLogScope): RunLogScope { + if (scope === 'tail-100') return 'tail-500'; + return 'all'; +} + +export function beginRunLogLoad(state: RunLogWindowState, scope: RunLogScope): RunLogWindowState { + return { ...state, loadingScope: scope }; +} + +export function replaceRunLogWindow( + state: RunLogWindowState, + scope: RunLogScope, + chunks: IncomingRunLogChunk[], +): RunLogWindowState { + return { + ...state, + scope, + tailLines: RUN_LOG_TAIL_LINES[scope], + fullyLoaded: scope === 'all', + chunks: mergeChunks(chunks, state.pendingLiveChunks), + pendingLiveChunks: [], + loadingScope: undefined, + }; +} + +export function appendLiveRunLogChunk( + state: RunLogWindowState, + chunk: IncomingRunLogChunk, +): RunLogWindowState { + const normalized = normalizeChunk(chunk, state.chunks.length + state.pendingLiveChunks.length); + return { + ...state, + chunks: mergeChunks(state.chunks, [normalized]), + pendingLiveChunks: state.loadingScope + ? mergeChunks(state.pendingLiveChunks, [normalized]) + : state.pendingLiveChunks, + }; +} + +function mergeChunks( + baseline: IncomingRunLogChunk[], + additions: IncomingRunLogChunk[], +): RunLogWindowChunk[] { + const byOffset = new Map(); + baseline.forEach((chunk, index) => byOffset.set(chunk.offset, normalizeChunk(chunk, index))); + additions.forEach((chunk, index) => byOffset.set(chunk.offset, normalizeChunk(chunk, baseline.length + index))); + return [...byOffset.values()].sort(compareChunks); +} + +function normalizeChunk(chunk: IncomingRunLogChunk, fallbackSequence: number): RunLogWindowChunk { + return { + data: chunk.data, + createdAt: chunk.createdAt ?? '', + offset: chunk.offset, + sequence: chunk.sequence ?? fallbackSequence, + }; +} + +function compareChunks(left: RunLogWindowChunk, right: RunLogWindowChunk): number { + if (left.offset < right.offset) return -1; + if (left.offset > right.offset) return 1; + return left.sequence - right.sequence; +} diff --git a/src/lib/run-scheduler-evidence.test.ts b/src/lib/run-scheduler-evidence.test.ts new file mode 100644 index 0000000..1e5b5fe --- /dev/null +++ b/src/lib/run-scheduler-evidence.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + ListSchedulerEventsResponse, + SchedulerEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { findSchedulerRunEvidence, stableProjectRunId } from './run-scheduler-evidence'; + +describe('stableProjectRunId', () => { + afterEach(() => vi.unstubAllGlobals()); + + test('matches the backend stable ID vector', async () => { + await expect(stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-run-1')) + .resolves.toBe('93114a8fb7f5c877f7afb25d5efb06bff4100665458170783504846711b15f1f'); + }); + + test('uses UTF-8 byte lengths for Unicode parts', async () => { + await expect(stableProjectRunId('项目一', '智能体', 'scheduler', '调度运行一')) + .resolves.toBe('0c956cf6028eb72cd3c320462d3eb2ee4a0cbf87ea07b55b90da11a1006ea105'); + }); + + test('matches the backend when Web Crypto subtle digest is unavailable', async () => { + vi.stubGlobal('crypto', {}); + await expect(stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-run-1')) + .resolves.toBe('93114a8fb7f5c877f7afb25d5efb06bff4100665458170783504846711b15f1f'); + }); +}); + +describe('findSchedulerRunEvidence', () => { + test('finds the exact loader run and continues through its start event', async () => { + const projectRunId = await stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-target:agent:1'); + const cursors: string[] = []; + const result = await findSchedulerRunEvidence({ + projectId: 'project-1', + agentName: 'worker', + triggerId: 'trigger-1', + projectRunId, + }, async request => { + cursors.push(request.cursor); + if (!request.cursor) return new ListSchedulerEventsResponse({ + events: [ + new SchedulerEvent({ id: 'other', runId: 'loader-other', triggerId: 'trigger-1', type: 'loader.run.completed' }), + new SchedulerEvent({ id: 'agent', runId: 'loader-target', triggerId: 'trigger-1', type: 'loader.agent.completed' }), + new SchedulerEvent({ id: 'done', runId: 'loader-target', triggerId: 'trigger-1', type: 'loader.run.completed' }), + ], + nextCursor: 'next', + }); + return new ListSchedulerEventsResponse({ + events: [ + new SchedulerEvent({ id: 'noise', runId: 'loader-noise', triggerId: 'trigger-2', type: 'loader.run.started' }), + new SchedulerEvent({ id: 'start', runId: 'loader-target', triggerId: 'trigger-1', type: 'loader.run.started' }), + ], + }); + }); + + expect(cursors).toEqual(['', 'next']); + expect(result.loaderRunId).toBe('loader-target'); + expect(result.events.map(event => event.id)).toEqual(['agent', 'done', 'start']); + }); + + test('finds a sequenced Project Run before its Scheduler Agent event completes', async () => { + const projectRunId = await stableProjectRunId('project-1', 'worker', 'scheduler', 'loader-running:agent:1'); + + const result = await findSchedulerRunEvidence({ + projectId: 'project-1', agentName: 'worker', triggerId: 'trigger-1', projectRunId, + }, async () => new ListSchedulerEventsResponse({ + events: [new SchedulerEvent({ id: 'start', runId: 'loader-running', triggerId: 'trigger-1', type: 'loader.run.started' })], + })); + + expect(result.loaderRunId).toBe('loader-running'); + expect(result.events.map(event => event.id)).toEqual(['start']); + }); + + test('returns no evidence instead of guessing from trigger ID', async () => { + const result = await findSchedulerRunEvidence({ + projectId: 'project-1', agentName: 'worker', triggerId: 'trigger-1', projectRunId: 'not-a-match', + }, async () => new ListSchedulerEventsResponse({ + events: [new SchedulerEvent({ id: 'same-trigger', runId: 'loader-other', triggerId: 'trigger-1' })], + })); + + expect(result).toEqual({ loaderRunId: '', events: [] }); + }); + + test('rejects a repeated pagination cursor', async () => { + await expect(findSchedulerRunEvidence({ + projectId: 'project-1', agentName: 'worker', triggerId: '', projectRunId: 'not-a-match', + }, async () => new ListSchedulerEventsResponse({ nextCursor: 'repeat' }))) + .rejects.toThrow('repeated cursor'); + }); +}); diff --git a/src/lib/run-scheduler-evidence.ts b/src/lib/run-scheduler-evidence.ts new file mode 100644 index 0000000..94846dc --- /dev/null +++ b/src/lib/run-scheduler-evidence.ts @@ -0,0 +1,166 @@ +import { + ListSchedulerEventsRequest, + type ListSchedulerEventsResponse, + type SchedulerEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export type SchedulerEventPageFetcher = ( + request: ListSchedulerEventsRequest, +) => Promise; + +export interface SchedulerRunEvidenceInput { + projectId: string; + agentName: string; + triggerId: string; + projectRunId: string; +} + +export interface SchedulerRunEvidence { + loaderRunId: string; + events: SchedulerEvent[]; +} + +function appendPart(target: number[], value: string) { + const bytes = new TextEncoder().encode(value.trim()); + const length = BigInt(bytes.byteLength); + for (let shift = 56n; shift >= 0n; shift -= 8n) { + target.push(Number((length >> shift) & 0xffn)); + } + target.push(...bytes); +} + +const sha256Constants = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +function rotateRight(value: number, count: number): number { + return (value >>> count) | (value << (32 - count)); +} + +function sha256Fallback(input: Uint8Array): Uint8Array { + const paddedLength = Math.ceil((input.length + 9) / 64) * 64; + const padded = new Uint8Array(paddedLength); + padded.set(input); + padded[input.length] = 0x80; + const bitLength = BigInt(input.length) * 8n; + for (let index = 0; index < 8; index++) padded[paddedLength - 1 - index] = Number((bitLength >> BigInt(index * 8)) & 0xffn); + + const hash = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const words = new Uint32Array(64); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let index = 0; index < 16; index++) { + const start = offset + index * 4; + words[index] = ((padded[start] << 24) | (padded[start + 1] << 16) | (padded[start + 2] << 8) | padded[start + 3]) >>> 0; + } + for (let index = 16; index < 64; index++) { + const left = words[index - 15]; + const right = words[index - 2]; + const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3); + const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10); + words[index] = (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0; + } + let [a, b, c, d, e, f, g, h] = hash; + for (let index = 0; index < 64; index++) { + const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); + const choice = (e & f) ^ (~e & g); + const temporary1 = (h + sum1 + choice + sha256Constants[index] + words[index]) >>> 0; + const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); + const majority = (a & b) ^ (a & c) ^ (b & c); + const temporary2 = (sum0 + majority) >>> 0; + h = g; g = f; f = e; e = (d + temporary1) >>> 0; d = c; c = b; b = a; a = (temporary1 + temporary2) >>> 0; + } + hash[0] = (hash[0] + a) >>> 0; hash[1] = (hash[1] + b) >>> 0; + hash[2] = (hash[2] + c) >>> 0; hash[3] = (hash[3] + d) >>> 0; + hash[4] = (hash[4] + e) >>> 0; hash[5] = (hash[5] + f) >>> 0; + hash[6] = (hash[6] + g) >>> 0; hash[7] = (hash[7] + h) >>> 0; + } + const output = new Uint8Array(32); + for (let index = 0; index < hash.length; index++) { + output[index * 4] = hash[index] >>> 24; + output[index * 4 + 1] = hash[index] >>> 16; + output[index * 4 + 2] = hash[index] >>> 8; + output[index * 4 + 3] = hash[index]; + } + return output; +} + +export async function stableProjectRunId( + projectId: string, + agentName: string, + source: string, + clientRequestId: string, +): Promise { + const encoded: number[] = []; + for (const part of ['run', projectId, agentName, source, clientRequestId]) appendPart(encoded, part); + const input = new Uint8Array(encoded); + const subtle = globalThis.crypto?.subtle; + const digest = subtle ? new Uint8Array(await subtle.digest('SHA-256', input)) : sha256Fallback(input); + return [...digest].map(value => value.toString(16).padStart(2, '0')).join(''); +} + +function normalizedIdentity(value: string): string { + return value.trim().toLowerCase().replace(/^sha256:/, ''); +} + +export async function findSchedulerRunEvidence( + input: SchedulerRunEvidenceInput, + fetchPage: SchedulerEventPageFetcher, +): Promise { + const seenCursors = new Set(); + const eventsByRun = new Map(); + let cursor = ''; + let loaderRunId = ''; + + while (true) { + const page = await fetchPage(new ListSchedulerEventsRequest({ + project: { projectId: input.projectId }, + agentName: input.agentName, + limit: 500, + cursor, + })); + for (const event of page.events) { + if (!event.runId) continue; + eventsByRun.set(event.runId, [...(eventsByRun.get(event.runId) ?? []), event]); + } + const candidates = [...new Set([...eventsByRun.values()].flat() + .filter(event => event.runId) + .sort((left, right) => Number(right.triggerId === input.triggerId) - Number(left.triggerId === input.triggerId)) + .map(event => event.runId))]; + + if (!loaderRunId) { + for (const candidate of candidates) { + const agentRunCount = (eventsByRun.get(candidate) ?? []) + .filter(event => event.type === 'loader.agent.completed' || event.type === 'loader.agent.failed').length + 1; + let matches = false; + for (let sequence = 1; sequence <= agentRunCount; sequence++) { + const clientRequestId = `${candidate}:agent:${sequence}`; + if (normalizedIdentity(await stableProjectRunId(input.projectId, input.agentName, 'scheduler', clientRequestId)) === normalizedIdentity(input.projectRunId)) { + matches = true; + break; + } + } + if (matches) { + loaderRunId = candidate; + break; + } + } + } + + if (loaderRunId) { + const collected = eventsByRun.get(loaderRunId) ?? []; + if (collected.some(event => event.type === 'loader.run.started')) return { loaderRunId, events: collected }; + } + + if (!page.nextCursor) return loaderRunId ? { loaderRunId, events: eventsByRun.get(loaderRunId) ?? [] } : { loaderRunId: '', events: [] }; + if (seenCursors.has(page.nextCursor)) throw new Error('Scheduler event pagination returned repeated cursor'); + seenCursors.add(page.nextCursor); + cursor = page.nextCursor; + } +} diff --git a/src/lib/runtime-inventory.test.js b/src/lib/runtime-inventory.test.js new file mode 100644 index 0000000..cf1b504 --- /dev/null +++ b/src/lib/runtime-inventory.test.js @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; +import { Code } from '@connectrpc/connect'; +import { classifySandboxProbe, groupSandboxInventory, sortSandboxInventory } from './runtime-inventory'; + +describe('groupSandboxInventory', () => { + test('deduplicates v2 run sandboxes and retains newest run context', () => { + const result = groupSandboxInventory([ + { runId: 'old', sandboxId: 'sb-1', agentName: 'writer', updatedAt: '2026-07-14T01:00:00Z' }, + { runId: 'new', sandboxId: 'sb-1', agentName: 'writer', updatedAt: '2026-07-14T02:00:00Z' }, + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + sandboxId: 'sb-1', latestRunId: 'new', agentName: 'writer', runCount: 2, + firstSeenAt: '2026-07-14T01:00:00Z', updatedAt: '2026-07-14T02:00:00Z', + }); + expect(result[0].runs.map(run => run.runId)).toEqual(['new', 'old']); + }); + + test('ignores runs without sandbox ids and uses created time as a fallback', () => { + const result = groupSandboxInventory([ + { runId: 'ignored', sandboxId: '', agentName: 'writer', createdAt: '2026-07-14T00:00:00Z' }, + { runId: 'kept', sandboxId: 'sb-1', agentName: 'writer', createdAt: '2026-07-14T01:00:00Z' }, + ]); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ firstSeenAt: '2026-07-14T01:00:00Z', updatedAt: '2026-07-14T01:00:00Z' }); + }); +}); + +describe('classifySandboxProbe', () => { + test('maps structured V2 probe outcomes to lifecycle states', () => { + expect(classifySandboxProbe()).toEqual({ lifecycle: 'running', statsUnavailable: false }); + expect(classifySandboxProbe({ code: Code.NotFound, rawMessage: 'missing' }).lifecycle).toBe('destroyed'); + expect(classifySandboxProbe({ code: Code.FailedPrecondition, rawMessage: 'sandbox sb is not running' }).lifecycle).toBe('stopped'); + expect(classifySandboxProbe({ code: Code.Unimplemented, rawMessage: 'stats unsupported' })).toEqual({ lifecycle: 'running', statsUnavailable: true, message: 'stats unsupported' }); + expect(classifySandboxProbe({ code: Code.FailedPrecondition, rawMessage: 'another precondition' }).lifecycle).toBe('unknown'); + expect(classifySandboxProbe({ code: Code.Unavailable, rawMessage: 'offline' }).lifecycle).toBe('unknown'); + }); +}); + +describe('sortSandboxInventory', () => { + test('orders actionable states first and newest within one state', () => { + const items = groupSandboxInventory([ + { runId: 'destroyed', sandboxId: 'destroyed', agentName: 'writer', updatedAt: '2026-07-14T05:00:00Z' }, + { runId: 'unknown', sandboxId: 'unknown', agentName: 'writer', updatedAt: '2026-07-14T04:00:00Z' }, + { runId: 'stopped-old', sandboxId: 'stopped-old', agentName: 'writer', updatedAt: '2026-07-14T01:00:00Z' }, + { runId: 'stopped-new', sandboxId: 'stopped-new', agentName: 'writer', updatedAt: '2026-07-14T03:00:00Z' }, + { runId: 'running', sandboxId: 'running', agentName: 'writer', updatedAt: '2026-07-14T02:00:00Z' }, + ]); + const sorted = sortSandboxInventory(items, { + running: 'running', 'stopped-old': 'stopped', 'stopped-new': 'stopped', + unknown: 'unknown', destroyed: 'destroyed', + }); + expect(sorted.map(item => item.sandboxId)).toEqual(['running', 'stopped-new', 'stopped-old', 'unknown', 'destroyed']); + }); +}); diff --git a/src/lib/runtime-inventory.ts b/src/lib/runtime-inventory.ts new file mode 100644 index 0000000..940abbf --- /dev/null +++ b/src/lib/runtime-inventory.ts @@ -0,0 +1,89 @@ +import { Code, ConnectError } from '@connectrpc/connect'; +import type { RunSummary } from '../gen/agentcompose/v2/agentcompose_pb'; + +export interface SandboxInventory { + sandboxId: string; + latestRunId: string; + agentName: string; + firstSeenAt: string; + updatedAt: string; + runCount: number; + runs: InventoryRun[]; +} + +export type InventoryRun = Pick + & Partial>; + +export type SandboxLifecycle = 'detecting' | 'running' | 'stopped' | 'destroyed' | 'unknown'; + +export interface SandboxProbeResult { + lifecycle: SandboxLifecycle; + statsUnavailable: boolean; + message?: string; +} + +function errorEvidence(error: unknown): { code: Code; message: string } { + if (error && typeof error === 'object' && 'code' in error) { + const candidate = error as { code?: Code; rawMessage?: string; message?: string }; + return { code: candidate.code ?? Code.Unknown, message: candidate.rawMessage || candidate.message || '' }; + } + const connected = ConnectError.from(error); + return { code: connected.code, message: connected.rawMessage || connected.message }; +} + +export function classifySandboxProbe(error?: unknown): SandboxProbeResult { + if (error == null) return { lifecycle: 'running', statsUnavailable: false }; + const evidence = errorEvidence(error); + if (evidence.code === Code.NotFound) { + return { lifecycle: 'destroyed', statsUnavailable: false, message: evidence.message }; + } + if (evidence.code === Code.FailedPrecondition && /\bnot running\b/i.test(evidence.message)) { + return { lifecycle: 'stopped', statsUnavailable: false, message: evidence.message }; + } + if (evidence.code === Code.Unimplemented) { + return { lifecycle: 'running', statsUnavailable: true, message: evidence.message }; + } + return { lifecycle: 'unknown', statsUnavailable: false, message: evidence.message }; +} + +export function groupSandboxInventory(runs: readonly InventoryRun[]): SandboxInventory[] { + const grouped = new Map(); + const sorted = [...runs] + .filter(run => run.sandboxId) + .sort((left, right) => (right.updatedAt || right.createdAt).localeCompare(left.updatedAt || left.createdAt)); + + for (const run of sorted) { + const sandboxRuns = grouped.get(run.sandboxId) ?? []; + sandboxRuns.push(run); + grouped.set(run.sandboxId, sandboxRuns); + } + + return [...grouped.entries()].map(([sandboxId, sandboxRuns]) => { + const newest = sandboxRuns[0]; + const oldest = sandboxRuns[sandboxRuns.length - 1]; + return { + sandboxId, + latestRunId: newest.runId, + agentName: newest.agentName, + firstSeenAt: oldest.createdAt || oldest.updatedAt, + updatedAt: newest.updatedAt || newest.createdAt, + runCount: sandboxRuns.length, + runs: sandboxRuns, + }; + }); +} + +const lifecycleOrder: Record = { + running: 0, stopped: 1, unknown: 2, detecting: 2, destroyed: 3, +}; + +export function sortSandboxInventory( + items: readonly SandboxInventory[], + lifecycle: Readonly>, +): SandboxInventory[] { + return [...items].sort((left, right) => { + const stateDifference = lifecycleOrder[lifecycle[left.sandboxId] ?? 'detecting'] + - lifecycleOrder[lifecycle[right.sandboxId] ?? 'detecting']; + return stateDifference || right.updatedAt.localeCompare(left.updatedAt); + }); +} diff --git a/src/lib/runtime-timeline.test.js b/src/lib/runtime-timeline.test.js new file mode 100644 index 0000000..684ba74 --- /dev/null +++ b/src/lib/runtime-timeline.test.js @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test'; +import { buildRuntimeTimeline } from './runtime-timeline'; + +const summary = { + startedAt: '2026-07-13T14:32:45.000Z', + completedAt: '2026-07-13T14:32:58.000Z', + createdAt: '2026-07-13T14:32:44.000Z', + updatedAt: '2026-07-13T14:32:59.000Z', +}; + +function completeOptions(overrides = {}) { + return { + summary: { ...summary, exitCode: 0, error: '', schedulerId: '', triggerId: '', sandboxId: '' }, + sourceText: '手动运行', + statusText: '成功', + terminal: true, + actualPrompt: 'prompt', + output: '', + resultJson: '', + warnings: [], + cleanupError: '', + logError: '', + logChunks: [], + ...overrides, + }; +} + +describe('buildRuntimeTimeline', () => { + test('projects every available run field without hiding or deduplicating content', () => { + const entries = buildRuntimeTimeline({ + summary: { + startedAt: '2026-07-15T01:00:00Z', + completedAt: '2026-07-15T01:01:00Z', + updatedAt: '2026-07-15T01:01:01Z', + exitCode: 9, + error: 'agent failed\nstack line', + schedulerId: 'scheduler-1', + triggerId: 'nightly', + sandboxId: 'sandbox-1', + }, + sourceText: '调度器运行', + statusText: '失败', + terminal: true, + actualPrompt: 'same', + output: 'same', + resultJson: 'same', + warnings: ['same', 'warning-2'], + cleanupError: 'cleanup failed', + logError: 'rpc failed\ntransport detail', + logChunks: [{ data: 'same', createdAt: '2026-07-15T01:00:30Z', sequence: 0, offset: 4n }], + }); + + expect(entries.filter((entry) => entry.content.includes('same'))).toHaveLength(5); + expect(entries.map((entry) => entry.kind)).toEqual(expect.arrayContaining([ + 'run', 'prompt', 'scheduler', 'sandbox', 'process', 'output', 'result', 'warning', 'error', + ])); + expect(entries.filter((entry) => entry.kind === 'error').map((entry) => entry.content)).toEqual( + expect.arrayContaining(['agent failed\nstack line', 'cleanup failed', 'rpc failed\ntransport detail']), + ); + expect(entries.some((entry) => entry.content === '退出码 9')).toBe(true); + expect(entries.find((entry) => entry.kind === 'process')).toMatchObject({ + source: '执行输出', + level: 'info', + offset: 4n, + timestampInferred: false, + }); + expect(entries.find((entry) => entry.source === 'scheduler')).toMatchObject({ content: 'scheduler-1', kind: 'scheduler' }); + expect(entries.find((entry) => entry.source === 'trigger')).toMatchObject({ content: 'nightly', kind: 'scheduler' }); + }); + + test('marks inferred timestamps and preserves receive order for equal timestamps', () => { + const entries = buildRuntimeTimeline(completeOptions({ + logChunks: [ + { data: 'first', createdAt: summary.completedAt, sequence: 7, offset: 10n }, + { data: 'second', createdAt: summary.completedAt, sequence: 8, offset: 20n }, + ], + })); + + expect(entries.find((entry) => entry.kind === 'prompt')).toMatchObject({ + timestampInferred: true, + timestampBasis: 'run-start', + }); + expect(entries.filter((entry) => entry.kind === 'process').map((entry) => entry.content)) + .toEqual(['first', 'second']); + }); + + test('uses updatedAt as the inferred terminal timestamp for unfinished runs', () => { + const entries = buildRuntimeTimeline(completeOptions({ + summary: { ...summary, completedAt: '', exitCode: undefined }, + output: 'partial output', + warnings: ['still running'], + })); + + expect(entries.find((entry) => entry.kind === 'output')).toMatchObject({ + timestamp: summary.updatedAt, + timestampInferred: true, + timestampBasis: 'run-updated', + }); + expect(entries.find((entry) => entry.kind === 'warning')).toMatchObject({ + timestamp: summary.updatedAt, + timestampBasis: 'run-updated', + level: 'warning', + }); + expect(entries.some((entry) => entry.content.startsWith('运行结束'))).toBe(false); + }); + + test('omits protobuf-default exit code evidence for nonterminal runs', () => { + const entries = buildRuntimeTimeline(completeOptions({ + terminal: false, + summary: { ...summary, completedAt: '', exitCode: 0 }, + })); + expect(entries.some((entry) => entry.content.startsWith('退出码'))).toBe(false); + }); + + test('marks a run start inferred when startedAt falls back to createdAt', () => { + const entries = buildRuntimeTimeline(completeOptions({ + summary: { ...summary, startedAt: '', exitCode: 0 }, + })); + expect(entries.find((entry) => entry.content.startsWith('运行开始'))).toMatchObject({ + timestamp: summary.createdAt, + timestampInferred: true, + timestampBasis: 'run-start', + }); + }); + + test('adds overlapping artifact tags without removing existing filter meanings', () => { + const entries = buildRuntimeTimeline(completeOptions({ + output: 'final answer', + resultJson: '{"ok":true}', + logChunks: [{ data: 'runtime response', createdAt: summary.completedAt, sequence: 1 }], + summary: { ...summary, exitCode: 1, error: 'failed' }, + })); + + expect(entries.find((entry) => entry.kind === 'output').filterTags).toEqual(expect.arrayContaining(['message', 'artifact'])); + expect(entries.find((entry) => entry.kind === 'result').filterTags).toEqual(['artifact']); + expect(entries.find((entry) => entry.content === 'runtime response').filterTags).toContain('artifact'); + expect(entries.find((entry) => entry.content === 'failed').filterTags).toEqual(expect.arrayContaining(['run', 'problem'])); + }); +}); diff --git a/src/lib/runtime-timeline.ts b/src/lib/runtime-timeline.ts new file mode 100644 index 0000000..942eb3d --- /dev/null +++ b/src/lib/runtime-timeline.ts @@ -0,0 +1,177 @@ +import { parseAgentRunTranscript } from './agent-run-transcript'; + +export type RuntimeTimelineKind = + | 'run' + | 'prompt' + | 'scheduler' + | 'sandbox' + | 'process' + | 'tool' + | 'log' + | 'output' + | 'result' + | 'warning' + | 'error'; + +export type RuntimeTimelineFilterTag = 'message' | 'activity' | 'run' | 'artifact' | 'problem'; + +export interface RuntimeTimelineSummary { + startedAt?: string; + completedAt?: string; + createdAt?: string; + updatedAt?: string; + exitCode?: number; + error?: string; + schedulerId?: string; + triggerId?: string; + sandboxId?: string; +} + +export interface RuntimeLogChunk { + data: string; + createdAt: string; + sequence: number; + offset?: bigint; +} + +export interface RuntimeTimelineEntry { + id: string; + timestamp: string; + sortTime: number; + sequence: number; + kind: RuntimeTimelineKind; + source: string; + level: 'debug' | 'info' | 'warning' | 'error'; + content: string; + timestampInferred: boolean; + timestampBasis?: 'run-start' | 'run-end' | 'run-updated'; + offset?: bigint; + filterTags?: RuntimeTimelineFilterTag[]; +} + +export interface BuildRuntimeTimelineOptions { + summary: RuntimeTimelineSummary; + terminal: boolean; + sourceText: string; + statusText: string; + actualPrompt: string; + output: string; + resultJson: string; + warnings: string[]; + cleanupError: string; + logError: string; + logChunks: RuntimeLogChunk[]; +} + +export const runtimeTimelineLabels: Record = { + run: 'RUN', + prompt: 'PROMPT', + scheduler: 'SCHEDULER', + sandbox: 'SANDBOX', + process: 'PROCESS', + tool: 'TOOL', + log: 'LOG', + output: 'OUTPUT', + result: 'RESULT', + warning: 'WARNING', + error: 'ERROR', +}; + +function timestampValue(timestamp: string, fallback: number): number { + const value = Date.parse(timestamp); + return Number.isNaN(value) ? fallback : value; +} + +function defaultFilterTags(kind: RuntimeTimelineKind): RuntimeTimelineFilterTag[] { + switch (kind) { + case 'prompt': return ['message']; + case 'output': return ['message', 'artifact']; + case 'result': return ['artifact']; + case 'log': + case 'process': return ['activity', 'artifact']; + case 'tool': return ['activity']; + case 'warning': return ['problem']; + case 'error': return ['run', 'problem']; + case 'run': + case 'scheduler': + case 'sandbox': return ['run']; + } +} + +export function buildRuntimeTimeline(options: BuildRuntimeTimelineOptions): RuntimeTimelineEntry[] { + const { summary } = options; + const startTimestamp = summary.startedAt || summary.createdAt || summary.updatedAt || ''; + const terminalTimestamp = summary.completedAt || summary.updatedAt || startTimestamp; + const startTime = timestampValue(startTimestamp, 0); + const terminalBasis = summary.completedAt ? 'run-end' : 'run-updated'; + const entries: RuntimeTimelineEntry[] = []; + let sequence = 0; + + function add( + kind: RuntimeTimelineKind, + source: string, + level: RuntimeTimelineEntry['level'], + content: string, + timestamp: string, + timestampInferred: boolean, + timestampBasis?: RuntimeTimelineEntry['timestampBasis'], + offset?: bigint, + ) { + if (!content) return; + const itemSequence = sequence++; + entries.push({ + id: `${kind}-${timestamp || 'unknown'}-${itemSequence}`, + timestamp, + sortTime: timestampValue(timestamp, startTime), + sequence: itemSequence, + kind, + source, + level, + content, + timestampInferred, + ...(timestampBasis ? { timestampBasis } : {}), + ...(offset === undefined ? {} : { offset }), + filterTags: defaultFilterTags(kind), + }); + } + + const hasStartedAt = Boolean(summary.startedAt); + add('run', 'run', 'info', `运行开始 · ${options.sourceText}`, startTimestamp, !hasStartedAt, hasStartedAt ? undefined : 'run-start'); + add('prompt', 'prompt', 'info', options.actualPrompt, startTimestamp, true, 'run-start'); + add('scheduler', 'scheduler', 'info', summary.schedulerId || '', startTimestamp, true, 'run-start'); + add('scheduler', 'trigger', 'info', summary.triggerId || '', startTimestamp, true, 'run-start'); + add('sandbox', 'sandbox', 'info', summary.sandboxId || '', startTimestamp, true, 'run-start'); + + for (const chunk of options.logChunks) { + const hasTimestamp = Boolean(chunk.createdAt); + for (const segment of parseAgentRunTranscript(chunk.data)) { + add( + segment.kind === 'tool' ? 'tool' : segment.kind === 'diagnostic' ? 'error' : 'process', + segment.label, + segment.kind === 'diagnostic' ? 'error' : 'info', + segment.content, + chunk.createdAt || startTimestamp, + !hasTimestamp, + hasTimestamp ? undefined : 'run-start', + chunk.offset, + ); + } + } + + add('output', 'output', 'info', options.output, terminalTimestamp, true, terminalBasis); + add('result', 'result', 'info', options.resultJson, terminalTimestamp, true, terminalBasis); + for (const warning of options.warnings) { + add('warning', 'run', 'warning', warning, terminalTimestamp, true, terminalBasis); + } + add('error', 'run', 'error', summary.error || '', terminalTimestamp, true, terminalBasis); + add('error', 'cleanup', 'error', options.cleanupError, terminalTimestamp, true, terminalBasis); + add('error', 'log', 'error', options.logError, terminalTimestamp, true, terminalBasis); + if (options.terminal && summary.exitCode !== undefined) { + add('run', 'run', summary.exitCode === 0 ? 'info' : 'error', `退出码 ${summary.exitCode}`, terminalTimestamp, true, terminalBasis); + } + if (summary.completedAt) { + add('run', 'run', summary.error ? 'error' : 'info', `运行结束 · ${options.statusText}`, terminalTimestamp, false); + } + + return entries.sort((left, right) => left.sortTime - right.sortTime || left.sequence - right.sequence); +} diff --git a/src/lib/sandbox-detail.test.js b/src/lib/sandbox-detail.test.js new file mode 100644 index 0000000..af60dce --- /dev/null +++ b/src/lib/sandbox-detail.test.js @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; +import { ListSandboxHistoryResponse, ListSandboxRunEventsResponse, Sandbox, SandboxHistoryCell } from '../gen/agentcompose/v2/agentcompose_pb'; +import { buildSandboxDetailSnapshot, buildSandboxTimeline } from './sandbox-detail'; + +describe('buildSandboxDetailSnapshot', () => { + test('uses the direct sandbox record and history responses', () => { + const snapshot = buildSandboxDetailSnapshot( + new Sandbox({ sandboxId: 'sb-1', status: 'RUNNING' }), + new ListSandboxHistoryResponse({ cells: [{ id: 'cell-1', source: 'pwd' }], events: [{ id: 'event-1', message: 'ready' }], legacyHistory: true }), + new ListSandboxRunEventsResponse({ events: [{ id: 'run-event-1', text: 'agent response' }] }), + ); + + expect(snapshot.sandbox.sandboxId).toBe('sb-1'); + expect(snapshot.cells.map(cell => cell.id)).toEqual(['cell-1']); + expect(snapshot.events.map(event => event.id)).toEqual(['event-1']); + expect(snapshot.runEvents.map(event => event.id)).toEqual(['run-event-1']); + expect(snapshot.legacyHistory).toBe(true); + }); + + test('merges cell, sandbox, and run evidence into chronological timeline rows', () => { + const snapshot = buildSandboxDetailSnapshot( + new Sandbox({ sandboxId: 'sb-1' }), + new ListSandboxHistoryResponse({ + cells: [{ id: 'cell-1', source: 'pwd', stdout: '/work', success: true, createdAt: { seconds: 2n } }], + events: [{ id: 'event-1', type: 'ready', message: 'ready', createdAt: { seconds: 1n } }], + }), + new ListSandboxRunEventsResponse({ events: [{ id: 'run-event-1', runId: 'run-1', text: 'answer', success: true, createdAt: { seconds: 3n } }] }), + ); + + expect(buildSandboxTimeline(snapshot).map(entry => [entry.kind, entry.content])).toEqual([ + ['sandbox', 'ready'], + ['cell', '输入\npwd\n\n标准输出\n/work\n\n退出码 0'], + ['run', 'answer'], + ]); + }); +}); + +describe('buildSandboxTimeline', () => { + function timelineFor(cell) { + return buildSandboxTimeline({ + sandbox: new Sandbox({ sandboxId: 'sb-1' }), + cells: [new SandboxHistoryCell(cell)], + events: [], + runEvents: [], + legacyHistory: false, + })[0]; + } + + test('hides duplicated stderr for a successful completed agent cell', () => { + const entry = timelineFor({ + id: 'cell-1', source: '你好', stderr: '你好。', output: '你好。', + success: true, exitCode: 0, stopReason: 'completed', + }); + + expect(entry.content).toBe('输入\n你好\n\n结果\n你好。\n\n退出码 0 · completed'); + expect(entry.level).toBe('info'); + }); + + test('exposes input and result as separate timeline sections', () => { + const entry = timelineFor({ + id: 'cell-1', source: 'prompt', output: 'answer', + success: true, exitCode: 0, stopReason: 'completed', + }); + + expect(entry.sections).toEqual([ + { label: '输入', content: 'prompt', collapsible: true }, + { label: '结果', content: 'answer', collapsible: true }, + { label: '状态', content: '退出码 0 · completed', collapsible: false }, + ]); + }); + + test('labels distinct stderr from a successful agent cell as execution process', () => { + const entry = timelineFor({ + id: 'cell-1', type: 'agent', stderr: 'agent tool trace', output: 'answer', + success: true, exitCode: 0, stopReason: 'completed', + }); + + expect(entry.content).toContain('执行过程\nagent tool trace'); + expect(entry.content).not.toContain('标准错误'); + expect(entry.content).toContain('结果\nanswer'); + expect(entry.sections).toContainEqual({ label: '执行过程', content: 'agent tool trace', collapsible: true }); + expect(entry.level).toBe('info'); + }); + + test('keeps stderr from a successful command cell as standard error', () => { + const entry = timelineFor({ + id: 'cell-1', type: 'command', stderr: 'command warning', output: 'answer', + success: true, exitCode: 0, stopReason: 'completed', + }); + + expect(entry.content).toContain('标准错误\ncommand warning'); + expect(entry.level).toBe('error'); + }); + + test('keeps duplicated stderr for a failed cell', () => { + const entry = timelineFor({ + id: 'cell-1', stderr: 'request failed', output: 'request failed', + success: false, exitCode: 1, + }); + + expect(entry.content).toContain('标准错误\nrequest failed'); + expect(entry.content).toContain('结果\nrequest failed'); + expect(entry.level).toBe('error'); + }); +}); diff --git a/src/lib/sandbox-detail.ts b/src/lib/sandbox-detail.ts new file mode 100644 index 0000000..aaaab47 --- /dev/null +++ b/src/lib/sandbox-detail.ts @@ -0,0 +1,123 @@ +import type { + ListSandboxHistoryResponse, + ListSandboxRunEventsResponse, + RunEvent, + Sandbox, + SandboxHistoryCell, + SandboxHistoryEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export interface SandboxDetailSnapshot { + sandbox: Sandbox; + cells: SandboxHistoryCell[]; + events: SandboxHistoryEvent[]; + runEvents: RunEvent[]; + legacyHistory: boolean; +} + +export interface SandboxTimelineEntry { + id: string; + timestamp: string; + sortTime: number; + kind: 'cell' | 'sandbox' | 'run'; + label: string; + source: string; + content: string; + level: 'info' | 'error'; + sections?: SandboxTimelineSection[]; +} + +export interface SandboxTimelineSection { + label: string; + content: string; + collapsible: boolean; +} + +function timestamp(value?: { seconds: bigint; nanos: number }): string { + if (!value) return ''; + return new Date(Number(value.seconds) * 1000 + value.nanos / 1_000_000).toISOString(); +} + +export function buildSandboxTimeline(snapshot: SandboxDetailSnapshot): SandboxTimelineEntry[] { + const entries: SandboxTimelineEntry[] = []; + for (const cell of snapshot.cells) { + const createdAt = timestamp(cell.createdAt); + const duplicatedSuccessfulStderr = !cell.running + && cell.success + && cell.exitCode === 0 + && Boolean(cell.stderr) + && cell.stderr === cell.output; + const visibleStderr = duplicatedSuccessfulStderr ? '' : cell.stderr; + const agentProcessStderr = cell.type === 'agent' + && (cell.running || (cell.success && cell.exitCode === 0)); + const stderrLabel = agentProcessStderr ? '执行过程' : '标准错误'; + const status = !cell.running ? `退出码 ${cell.exitCode}${cell.stopReason ? ` · ${cell.stopReason}` : ''}` : ''; + const parts = [ + cell.source && `输入\n${cell.source}`, + cell.stdout && `标准输出\n${cell.stdout}`, + visibleStderr && `${stderrLabel}\n${visibleStderr}`, + cell.output && `结果\n${cell.output}`, + status, + ].filter(Boolean); + const sections = [ + cell.source && { label: '输入', content: cell.source, collapsible: true }, + cell.stdout && { label: '标准输出', content: cell.stdout, collapsible: false }, + visibleStderr && { label: stderrLabel, content: visibleStderr, collapsible: agentProcessStderr }, + cell.output && { label: '结果', content: cell.output, collapsible: true }, + status && { label: '状态', content: status, collapsible: false }, + ].filter((section): section is SandboxTimelineSection => Boolean(section)); + entries.push({ + id: `cell:${cell.id}`, + timestamp: createdAt, + sortTime: createdAt ? Date.parse(createdAt) : 0, + kind: 'cell', + label: cell.agent || cell.type || 'Cell', + source: cell.agentThreadId ? `Thread ${cell.agentThreadId}` : cell.id, + content: parts.join('\n\n') || (cell.running ? '运行中' : cell.success ? '成功' : '无输出'), + level: (visibleStderr && !agentProcessStderr) || (!cell.running && !cell.success) ? 'error' : 'info', + sections, + }); + } + for (const event of snapshot.events) { + const createdAt = timestamp(event.createdAt); + entries.push({ + id: `sandbox:${event.id}`, + timestamp: createdAt, + sortTime: createdAt ? Date.parse(createdAt) : 0, + kind: 'sandbox', + label: event.type || 'Sandbox 事件', + source: event.level || 'sandbox', + content: event.message || '无内容', + level: /error|fatal|failed/i.test(event.level) ? 'error' : 'info', + }); + } + for (const event of snapshot.runEvents) { + const createdAt = timestamp(event.createdAt); + const content = [event.text, event.payloadJson, event.stopReason && `停止原因:${event.stopReason}`].filter(Boolean).join('\n'); + entries.push({ + id: `run:${event.id}`, + timestamp: createdAt, + sortTime: createdAt ? Date.parse(createdAt) : 0, + kind: 'run', + label: event.name || event.agent || 'Run 事件', + source: event.runId || 'run', + content: content || `退出码 ${event.exitCode}`, + level: event.success || (!event.stopReason && event.exitCode === 0) ? 'info' : 'error', + }); + } + return entries.sort((left, right) => left.sortTime - right.sortTime || left.id.localeCompare(right.id)); +} + +export function buildSandboxDetailSnapshot( + sandbox: Sandbox, + history: ListSandboxHistoryResponse, + runHistory: ListSandboxRunEventsResponse, +): SandboxDetailSnapshot { + return { + sandbox, + cells: [...history.cells], + events: [...history.events], + runEvents: [...runHistory.events], + legacyHistory: history.legacyHistory, + }; +} diff --git a/src/lib/sandbox-inventory.test.ts b/src/lib/sandbox-inventory.test.ts new file mode 100644 index 0000000..3f133d7 --- /dev/null +++ b/src/lib/sandbox-inventory.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Sandbox } from '../gen/agentcompose/v2/agentcompose_pb'; +import { filterSandboxes, listAllSandboxes, sandboxLifecycle } from './sandbox-inventory'; + +describe('sandbox inventory', () => { + it('walks every cursor page exactly once', async () => { + const fetchPage = vi.fn() + .mockResolvedValueOnce({ sandboxes: [new Sandbox({ sandboxId: 'one' })], nextCursor: 'next' }) + .mockResolvedValueOnce({ sandboxes: [new Sandbox({ sandboxId: 'two' })], nextCursor: '' }); + + await expect(listAllSandboxes(fetchPage)).resolves.toHaveLength(2); + expect(fetchPage.mock.calls.map(([request]) => request.cursor)).toEqual(['', 'next']); + }); + + it('rejects a repeated cursor', async () => { + const fetchPage = vi.fn() + .mockResolvedValueOnce({ sandboxes: [], nextCursor: 'next' }) + .mockResolvedValueOnce({ sandboxes: [], nextCursor: 'next' }); + + await expect(listAllSandboxes(fetchPage)).rejects.toThrow(/repeated cursor/i); + }); + + it('filters by authoritative fields and legacy tags', () => { + const records = [ + new Sandbox({ sandboxId: 'direct', projectId: 'p1', agentName: 'collector' }), + new Sandbox({ sandboxId: 'tagged', tags: [{ name: 'project', value: 'p1' }, { name: 'agent', value: 'collector' }] }), + new Sandbox({ sandboxId: 'other', projectId: 'p2', agentName: 'collector' }), + new Sandbox({ sandboxId: 'direct-wins', projectId: 'p2', agentName: 'collector', tags: [{ name: 'project', value: 'p1' }] }), + ]; + + expect(filterSandboxes(records, { projectId: 'p1', agentName: 'collector' }).map(item => item.sandboxId)).toEqual(['direct', 'tagged']); + }); + + it('normalizes backend lifecycle values', () => { + expect(sandboxLifecycle('RUNNING')).toBe('running'); + expect(sandboxLifecycle('stopped')).toBe('stopped'); + expect(sandboxLifecycle('REMOVED')).toBe('destroyed'); + expect(sandboxLifecycle('pending')).toBe('unknown'); + }); +}); diff --git a/src/lib/sandbox-inventory.ts b/src/lib/sandbox-inventory.ts new file mode 100644 index 0000000..0f46b8e --- /dev/null +++ b/src/lib/sandbox-inventory.ts @@ -0,0 +1,52 @@ +import { + ListSandboxesRequest, + type ListSandboxesResponse, + type Sandbox, +} from '../gen/agentcompose/v2/agentcompose_pb'; + +export type SandboxPageFetcher = ( + request: ListSandboxesRequest, + options?: { signal?: AbortSignal }, +) => Promise; + +export async function listAllSandboxes(fetchPage: SandboxPageFetcher, signal?: AbortSignal): Promise { + const records: Sandbox[] = []; + const seen = new Set(); + let cursor = ''; + + while (true) { + const response = await fetchPage(new ListSandboxesRequest({ limit: 100, cursor }), { signal }); + records.push(...response.sandboxes); + if (!response.nextCursor) return records; + if (seen.has(response.nextCursor)) throw new Error(`Sandbox pagination returned repeated cursor: ${response.nextCursor}`); + seen.add(response.nextCursor); + cursor = response.nextCursor; + } +} + +function tagValue(record: Sandbox, name: string): string { + return record.tags.find(tag => tag.name === name)?.value ?? ''; +} + +export function filterSandboxes( + records: readonly Sandbox[], + target: { projectId: string; agentName?: string }, +): Sandbox[] { + return records.filter(record => { + const projectId = record.projectId || tagValue(record, 'project'); + const agentName = record.agentName || tagValue(record, 'agent'); + return projectId === target.projectId && (!target.agentName || agentName === target.agentName); + }); +} + +export function sandboxLifecycle(status: string): 'running' | 'stopped' | 'destroyed' | 'unknown' { + switch (status.trim().toLowerCase()) { + case 'running': return 'running'; + case 'stopped': + case 'exited': return 'stopped'; + case 'destroyed': + case 'removed': + case 'deleted': return 'destroyed'; + default: return 'unknown'; + } +} diff --git a/src/lib/sandbox-watch.test.ts b/src/lib/sandbox-watch.test.ts new file mode 100644 index 0000000..9de5e44 --- /dev/null +++ b/src/lib/sandbox-watch.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + Sandbox, SandboxHistoryCell, SandboxHistoryEvent, SandboxWatchEventType, StdioStream, WatchSandboxResponse, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import type { SandboxDetailSnapshot } from './sandbox-detail'; +import { mergeSandboxWatchEvent } from './sandbox-watch'; + +function snapshot(cell?: SandboxHistoryCell): SandboxDetailSnapshot { + return { sandbox: new Sandbox({ sandboxId: 's1', status: 'RUNNING' }), cells: cell ? [cell] : [], events: [], runEvents: [], legacyHistory: false }; +} + +describe('mergeSandboxWatchEvent', () => { + it('appends a streamed stdout chunk to the matching cell immutably', () => { + const initial = snapshot(new SandboxHistoryCell({ id: 'cell-1', stdout: 'a' })); + const next = mergeSandboxWatchEvent(initial, new WatchSandboxResponse({ + eventType: SandboxWatchEventType.CELL_OUTPUT, cellId: 'cell-1', chunk: 'b', stream: StdioStream.STDOUT, + })); + expect(next.cells[0].stdout).toBe('ab'); + expect(initial.cells[0].stdout).toBe('a'); + }); + + it('appends stderr separately', () => { + const next = mergeSandboxWatchEvent(snapshot(new SandboxHistoryCell({ id: 'cell-1', stderr: 'a' })), new WatchSandboxResponse({ + eventType: SandboxWatchEventType.CELL_OUTPUT, cellId: 'cell-1', chunk: 'b', stream: StdioStream.STDERR, + })); + expect(next.cells[0].stderr).toBe('ab'); + }); + + it('replaces lifecycle metadata from a sandbox update', () => { + const next = mergeSandboxWatchEvent(snapshot(), new WatchSandboxResponse({ + eventType: SandboxWatchEventType.SANDBOX_UPDATED, + sandbox: new Sandbox({ sandboxId: 's1', status: 'STOPPED' }), + })); + expect(next.sandbox.status).toBe('STOPPED'); + }); + + it('starts and completes cells in place without changing their order', () => { + const first = new SandboxHistoryCell({ id: 'cell-1', source: 'one' }); + const started = mergeSandboxWatchEvent(snapshot(first), new WatchSandboxResponse({ + eventType: SandboxWatchEventType.CELL_STARTED, cell: new SandboxHistoryCell({ id: 'cell-2', source: 'two', running: true }), + })); + const completed = mergeSandboxWatchEvent(started, new WatchSandboxResponse({ + eventType: SandboxWatchEventType.CELL_COMPLETED, cell: new SandboxHistoryCell({ id: 'cell-2', source: 'two', output: 'done', success: true }), + })); + expect(completed.cells.map(cell => cell.id)).toEqual(['cell-1', 'cell-2']); + expect(completed.cells[1]).toMatchObject({ output: 'done', success: true }); + }); + + it('preserves streamed output when a sparse completion payload arrives', () => { + const started = snapshot(new SandboxHistoryCell({ id: 'cell-1', source: 'echo hello', running: true })); + const withStdout = mergeSandboxWatchEvent(started, new WatchSandboxResponse({ + eventType: SandboxWatchEventType.CELL_OUTPUT, cellId: 'cell-1', chunk: 'hello\n', stream: StdioStream.STDOUT, + })); + const withStderr = mergeSandboxWatchEvent(withStdout, new WatchSandboxResponse({ + eventType: SandboxWatchEventType.CELL_OUTPUT, cellId: 'cell-1', chunk: 'warning\n', stream: StdioStream.STDERR, + })); + const completed = mergeSandboxWatchEvent(withStderr, new WatchSandboxResponse({ + eventType: SandboxWatchEventType.CELL_COMPLETED, + cell: new SandboxHistoryCell({ id: 'cell-1', running: false, success: true, exitCode: 0 }), + })); + + expect(completed.cells[0]).toMatchObject({ + source: 'echo hello', stdout: 'hello\n', stderr: 'warning\n', running: false, success: true, exitCode: 0, + }); + }); + + it('deduplicates cells and events by id', () => { + const cell = new SandboxHistoryCell({ id: 'cell-1', source: 'one' }); + const event = new SandboxHistoryEvent({ id: 'event-1', message: 'ready' }); + const withCell = mergeSandboxWatchEvent(snapshot(cell), new WatchSandboxResponse({ eventType: SandboxWatchEventType.CELL_STARTED, cell })); + const withEvent = mergeSandboxWatchEvent(withCell, new WatchSandboxResponse({ eventType: SandboxWatchEventType.EVENT_ADDED, event })); + const duplicate = mergeSandboxWatchEvent(withEvent, new WatchSandboxResponse({ eventType: SandboxWatchEventType.EVENT_ADDED, event })); + expect(duplicate.cells).toHaveLength(1); + expect(duplicate.events).toHaveLength(1); + }); +}); diff --git a/src/lib/sandbox-watch.ts b/src/lib/sandbox-watch.ts new file mode 100644 index 0000000..1ddd98c --- /dev/null +++ b/src/lib/sandbox-watch.ts @@ -0,0 +1,71 @@ +import { + SandboxHistoryCell, + SandboxWatchEventType, + StdioStream, + type WatchSandboxResponse, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import type { SandboxDetailSnapshot } from './sandbox-detail'; + +function mergeById(items: readonly T[], item?: T): T[] { + if (!item) return [...items]; + const index = items.findIndex(existing => existing.id === item.id); + if (index < 0) return [...items, item]; + const next = [...items]; + next[index] = item; + return next; +} + +function mergeCompletedCell(items: readonly SandboxHistoryCell[], completed?: SandboxHistoryCell): SandboxHistoryCell[] { + if (!completed) return [...items]; + const index = items.findIndex(cell => cell.id === completed.id); + if (index < 0) return [...items, completed]; + const current = items[index]; + const preserveIfEmpty = (next: string, previous: string) => next || previous; + const merged = new SandboxHistoryCell({ + ...current, + ...completed, + type: preserveIfEmpty(completed.type, current.type), + source: preserveIfEmpty(completed.source, current.source), + stdout: preserveIfEmpty(completed.stdout, current.stdout), + stderr: preserveIfEmpty(completed.stderr, current.stderr), + output: preserveIfEmpty(completed.output, current.output), + agent: preserveIfEmpty(completed.agent, current.agent), + agentThreadId: preserveIfEmpty(completed.agentThreadId, current.agentThreadId), + stopReason: preserveIfEmpty(completed.stopReason, current.stopReason), + createdAt: completed.createdAt || current.createdAt, + }); + const next = [...items]; + next[index] = merged; + return next; +} + +export function mergeSandboxWatchEvent( + snapshot: SandboxDetailSnapshot, + event: WatchSandboxResponse, +): SandboxDetailSnapshot { + switch (event.eventType) { + case SandboxWatchEventType.SANDBOX_UPDATED: + return event.sandbox ? { ...snapshot, sandbox: event.sandbox } : snapshot; + case SandboxWatchEventType.CELL_STARTED: + return event.cell ? { ...snapshot, cells: mergeById(snapshot.cells, event.cell) } : snapshot; + case SandboxWatchEventType.CELL_COMPLETED: + return event.cell ? { ...snapshot, cells: mergeCompletedCell(snapshot.cells, event.cell) } : snapshot; + case SandboxWatchEventType.CELL_OUTPUT: { + const index = snapshot.cells.findIndex(cell => cell.id === event.cellId); + if (index < 0 || !event.chunk) return snapshot; + const cell = snapshot.cells[index]; + const updated = new SandboxHistoryCell({ + ...cell, + stdout: event.stream === StdioStream.STDERR ? cell.stdout : cell.stdout + event.chunk, + stderr: event.stream === StdioStream.STDERR ? cell.stderr + event.chunk : cell.stderr, + }); + const cells = [...snapshot.cells]; + cells[index] = updated; + return { ...snapshot, cells }; + } + case SandboxWatchEventType.EVENT_ADDED: + return event.event ? { ...snapshot, events: mergeById(snapshot.events, event.event) } : snapshot; + default: + return snapshot; + } +} diff --git a/src/lib/sandboxes.test.js b/src/lib/sandboxes.test.js new file mode 100644 index 0000000..085f6d5 --- /dev/null +++ b/src/lib/sandboxes.test.js @@ -0,0 +1,17 @@ +import { expect, test } from 'bun:test'; +import { formatMetric, sandboxJupyterPath } from './sandboxes'; + +test('formats available and unavailable metrics', () => { + expect(formatMetric({ value: 12.34, unit: '%' })).toBe('12.3%'); + expect(formatMetric({ value: 0, unit: 'percent' })).toBe('0.0%'); + expect(formatMetric({ value: 628.1, unit: 'seconds' })).toBe('10m 28s'); + expect(formatMetric({ value: 90061, unit: 'seconds' })).toBe('1d 1h 1m'); + expect(formatMetric({ value: 790528, unit: 'bytes' })).toBe('772.0 KB'); + expect(formatMetric({ value: 8352129024, unit: 'bytes' })).toBe('7.8 GB'); + expect(formatMetric({ message: 'driver unsupported' })).toBe('不可用'); +}); + +test('builds an encoded same-origin Jupyter proxy path', () => { + expect(sandboxJupyterPath('sandbox/a b')).toBe('/jupyter/sandbox%2Fa%20b'); + expect(sandboxJupyterPath('sandbox-1', '/agent-compose/jupyter/')).toBe('/agent-compose/jupyter/sandbox-1'); +}); diff --git a/src/lib/sandboxes.ts b/src/lib/sandboxes.ts new file mode 100644 index 0000000..d9381a2 --- /dev/null +++ b/src/lib/sandboxes.ts @@ -0,0 +1,33 @@ +import type { MetricValue } from '../gen/agentcompose/v2/agentcompose_pb'; + +export function formatMetric(metric?: Pick): string { + if (metric?.value == null) return '不可用'; + if (/^(?:percent|%)$/i.test(metric.unit)) return `${metric.value.toFixed(1)}%`; + if (/^(?:seconds?|s)$/i.test(metric.unit)) { + if (Math.abs(metric.value) < 60) return `${metric.value.toFixed(1)}s`; + let remaining = Math.max(0, Math.round(metric.value)); + const days = Math.floor(remaining / 86400); + remaining %= 86400; + const hours = Math.floor(remaining / 3600); + remaining %= 3600; + const minutes = Math.floor(remaining / 60); + const seconds = remaining % 60; + const parts = [days && `${days}d`, hours && `${hours}h`, minutes && `${minutes}m`, !days && seconds && `${seconds}s`].filter(Boolean); + return parts.join(' '); + } + if (/^(?:bytes?|b)$/i.test(metric.unit)) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = metric.value; + let index = 0; + while (Math.abs(value) >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + return `${value.toFixed(1)} ${units[index]}`; + } + return `${metric.value.toFixed(1)}${metric.unit}`; +} + +export function sandboxJupyterPath(sandboxId: string, basePath = '/jupyter'): string { + return `${basePath.replace(/\/+$/, '')}/${encodeURIComponent(sandboxId.trim())}`; +} diff --git a/src/lib/scheduler-execution-links.test.ts b/src/lib/scheduler-execution-links.test.ts new file mode 100644 index 0000000..a69a59e --- /dev/null +++ b/src/lib/scheduler-execution-links.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from 'vitest'; +import { SchedulerEvent } from '../gen/agentcompose/v2/agentcompose_pb'; +import { extractSchedulerExecutionLinks } from './scheduler-execution-links'; + +test('extracts only named explicit links and retains introducing events', () => { + const result = extractSchedulerExecutionLinks([ + new SchedulerEvent({ id: 'e1', payloadJson: JSON.stringify({ sandboxId: ' box-1 ', cell_id: 'cell-1' }) }), + new SchedulerEvent({ id: 'e2', payloadJson: JSON.stringify({ result: { runId: 'run-1', sandbox_id: 'box-1' }, text: 'cell-fake' }) }), + ]); + expect(result.sandboxes).toEqual([{ id: 'box-1', introducedBy: ['e1', 'e2'] }]); + expect(result.cells).toEqual([{ id: 'cell-1', introducedBy: ['e1'] }]); + expect(result.runs).toEqual([{ id: 'run-1', introducedBy: ['e2'] }]); + expect(result.sandboxRuns).toEqual([{ sandboxId: 'box-1', runId: 'run-1', introducedBy: ['e2'] }]); + expect(result.sandboxCells).toEqual([{ sandboxId: 'box-1', cellId: 'cell-1', introducedBy: ['e1'] }]); + expect(result.warnings).toEqual([]); +}); + +test('preserves malformed payload as a warning without inventing links', () => { + const result = extractSchedulerExecutionLinks([new SchedulerEvent({ id: 'bad', payloadJson: '{' })]); + expect(result.sandboxes).toEqual([]); + expect(result.warnings).toEqual([{ eventId: 'bad', message: 'Scheduler event payload is not valid JSON' }]); +}); + +test('sorts resources lexically while retaining unique introducers in event order', () => { + const result = extractSchedulerExecutionLinks([ + new SchedulerEvent({ id: 'later-name', payloadJson: JSON.stringify({ cells: [{ cellId: 'cell-z' }, { cell_id: 'cell-a' }] }) }), + new SchedulerEvent({ id: 'duplicate', payloadJson: JSON.stringify({ cellId: ' cell-a ', nested: { cell_id: 'cell-a' } }) }), + ]); + + expect(result.cells).toEqual([ + { id: 'cell-a', introducedBy: ['later-name', 'duplicate'] }, + { id: 'cell-z', introducedBy: ['later-name'] }, + ]); +}); + +test('ignores empty, non-string, and arbitrary string values and the loader run id', () => { + const result = extractSchedulerExecutionLinks([ + new SchedulerEvent({ + id: 'event-1', + runId: 'loader-run', + payloadJson: JSON.stringify({ + sandboxId: ' ', + cellId: 42, + run_id: null, + output: 'run-fake', + nested: ['sandbox-fake', { message: 'cell-fake' }], + }), + }), + new SchedulerEvent({ id: 'event-2', payloadJson: '' }), + new SchedulerEvent({ id: 'event-3', payloadJson: ' ' }), + ]); + + expect(result).toEqual({ sandboxes: [], cells: [], runs: [], sandboxRuns: [], sandboxCells: [], warnings: [] }); +}); + +test('preserves exact sandbox and run pairs in separate nested objects', () => { + const result = extractSchedulerExecutionLinks([new SchedulerEvent({ + id: 'pairs', + payloadJson: JSON.stringify({ links: [ + { sandboxId: 'box-a', runId: 'run-a' }, + { sandbox_id: 'box-b', run_id: 'run-b' }, + { runId: 'unpaired-run' }, + ] }), + })]); + + expect(result.sandboxRuns).toEqual([ + { sandboxId: 'box-a', runId: 'run-a', introducedBy: ['pairs'] }, + { sandboxId: 'box-b', runId: 'run-b', introducedBy: ['pairs'] }, + ]); +}); + +test('preserves exact sandbox and cell pairs without creating a cross product', () => { + const result = extractSchedulerExecutionLinks([new SchedulerEvent({ + id: 'cell-pairs', + payloadJson: JSON.stringify({ links: [ + { sandboxId: 'box-a', cellId: 'shared-cell' }, + { sandboxId: 'box-b', cellId: 'cell-b' }, + { cellId: 'unpaired-cell' }, + ] }), + })]); + + expect(result.sandboxCells).toEqual([ + { sandboxId: 'box-a', cellId: 'shared-cell', introducedBy: ['cell-pairs'] }, + { sandboxId: 'box-b', cellId: 'cell-b', introducedBy: ['cell-pairs'] }, + ]); +}); diff --git a/src/lib/scheduler-execution-links.ts b/src/lib/scheduler-execution-links.ts new file mode 100644 index 0000000..90c6334 --- /dev/null +++ b/src/lib/scheduler-execution-links.ts @@ -0,0 +1,131 @@ +import type { SchedulerEvent } from '../gen/agentcompose/v2/agentcompose_pb'; + +export interface LinkedResourceId { + id: string; + introducedBy: string[]; +} + +export interface LinkWarning { + eventId: string; + message: string; +} + +export interface LinkedSandboxRun { + sandboxId: string; + runId: string; + introducedBy: string[]; +} + +export interface LinkedSandboxCell { + sandboxId: string; + cellId: string; + introducedBy: string[]; +} + +export interface SchedulerExecutionLinks { + sandboxes: LinkedResourceId[]; + cells: LinkedResourceId[]; + runs: LinkedResourceId[]; + sandboxRuns: LinkedSandboxRun[]; + sandboxCells: LinkedSandboxCell[]; + warnings: LinkWarning[]; +} + +type LinkKind = 'sandboxes' | 'cells' | 'runs'; + +const keyKind: ReadonlyMap = new Map([ + ['sandboxId', 'sandboxes'], ['sandbox_id', 'sandboxes'], + ['cellId', 'cells'], ['cell_id', 'cells'], + ['runId', 'runs'], ['run_id', 'runs'], +] as const); +type LinkMaps = Record>>; +type SandboxRunMap = Map }>; +type SandboxCellMap = Map }>; + +function directIds(value: Record, kind: LinkKind): string[] { + const ids = new Set(); + for (const [key, child] of Object.entries(value)) { + if (keyKind.get(key) !== kind || typeof child !== 'string') continue; + const id = child.trim(); + if (id) ids.add(id); + } + return [...ids]; +} + +function visit(value: unknown, eventId: string, links: LinkMaps, sandboxRuns: SandboxRunMap, sandboxCells: SandboxCellMap): void { + if (Array.isArray(value)) { + for (const item of value) visit(item, eventId, links, sandboxRuns, sandboxCells); + return; + } + + if (value === null || typeof value !== 'object') return; + + const record = value as Record; + for (const sandboxId of directIds(record, 'sandboxes')) { + for (const runId of directIds(record, 'runs')) { + const key = JSON.stringify([sandboxId, runId]); + const pair = sandboxRuns.get(key) ?? { sandboxId, runId, introducedBy: new Set() }; + pair.introducedBy.add(eventId); + sandboxRuns.set(key, pair); + } + for (const cellId of directIds(record, 'cells')) { + const key = JSON.stringify([sandboxId, cellId]); + const pair = sandboxCells.get(key) ?? { sandboxId, cellId, introducedBy: new Set() }; + pair.introducedBy.add(eventId); + sandboxCells.set(key, pair); + } + } + + for (const [key, child] of Object.entries(record)) { + const kind = keyKind.get(key); + if (kind && typeof child === 'string') { + const id = child.trim(); + if (id) { + const introducedBy = links[kind].get(id) ?? new Set(); + introducedBy.add(eventId); + links[kind].set(id, introducedBy); + } + } + visit(child, eventId, links, sandboxRuns, sandboxCells); + } +} + +function toLinkedResources(resources: Map>): LinkedResourceId[] { + return [...resources] + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([id, introducedBy]) => ({ id, introducedBy: [...introducedBy] })); +} + +export function extractSchedulerExecutionLinks(events: readonly SchedulerEvent[]): SchedulerExecutionLinks { + const links: LinkMaps = { + sandboxes: new Map(), + cells: new Map(), + runs: new Map(), + }; + const sandboxRuns: SandboxRunMap = new Map(); + const sandboxCells: SandboxCellMap = new Map(); + const warnings: LinkWarning[] = []; + + for (const event of events) { + if (!event.payloadJson.trim()) continue; + + try { + visit(JSON.parse(event.payloadJson), event.id, links, sandboxRuns, sandboxCells); + } catch { + warnings.push({ eventId: event.id, message: 'Scheduler event payload is not valid JSON' }); + } + } + + return { + sandboxes: toLinkedResources(links.sandboxes), + cells: toLinkedResources(links.cells), + runs: toLinkedResources(links.runs), + sandboxRuns: [...sandboxRuns.values()] + .sort((left, right) => left.sandboxId.localeCompare(right.sandboxId) || left.runId.localeCompare(right.runId)) + .map(pair => ({ ...pair, introducedBy: [...pair.introducedBy] })), + sandboxCells: [...sandboxCells.values()] + .sort((left, right) => left.sandboxId.localeCompare(right.sandboxId) || left.cellId.localeCompare(right.cellId)) + .map(pair => ({ ...pair, introducedBy: [...pair.introducedBy] })), + warnings, + }; +} diff --git a/src/lib/scheduler-full-execution.test.ts b/src/lib/scheduler-full-execution.test.ts new file mode 100644 index 0000000..d121b6a --- /dev/null +++ b/src/lib/scheduler-full-execution.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, test, vi } from 'vitest'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { + GetRunResponse, GetSandboxResponse, ListRunEventsResponse, ListSandboxHistoryResponse, + ListSandboxRunEventsResponse, ListSchedulerEventsResponse, RunDetail, RunEvent, RunEventKind, RunLogChunk, + RunSummary, Sandbox, SandboxHistoryCell, SchedulerEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { loadFullSchedulerExecution, type FullExecutionDependencies } from './scheduler-full-execution'; +import { stableProjectRunId } from './run-scheduler-evidence'; + +async function* chunks(...values: RunLogChunk[]) { yield* values; } + +function dependencies(overrides: Partial = {}): FullExecutionDependencies { + return { + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started' }), + ] })), + getSandbox: vi.fn(async request => new GetSandboxResponse({ sandbox: new Sandbox({ sandboxId: request.sandboxId }) })), + listSandboxHistory: vi.fn(async () => new ListSandboxHistoryResponse()), + listSandboxRunEvents: vi.fn(async () => new ListSandboxRunEventsResponse()), + getRun: vi.fn(async () => new GetRunResponse({ run: new RunDetail() })), + listRunEvents: vi.fn(async () => new ListRunEventsResponse({ historyAvailable: true })), + followRunLogs: vi.fn(() => chunks(new RunLogChunk({ isFinal: true }))), + ...overrides, + }; +} + +const input = { projectId: 'project', agentName: 'agent', schedulerRunId: 'scheduler-run' }; + +describe('loadFullSchedulerExecution', () => { + test('exposes a Sandbox discovered from an associated Run detail', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"runId":"run-1"}' }), + ] })), + getRun: vi.fn(async () => new GetRunResponse({ run: new RunDetail({ + summary: new RunSummary({ runId: 'run-1', sandboxId: 'sandbox-from-run' }), + }) })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + + expect(result.sandboxIds).toEqual(['sandbox-from-run']); + }); + + test('pages scheduler history until the target start and exhausts resource cursors', async () => { + const schedulerCursors: string[] = []; + const sandboxCursors: string[] = []; + const runCursors: string[] = []; + const deps = dependencies({ + listSchedulerEvents: vi.fn(async request => { + schedulerCursors.push(request.cursor); + return request.cursor + ? new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"sandboxId":"box","runId":"run"}' })] }) + : new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'other', runId: 'other' })], nextCursor: 'scheduler-2' }); + }), + listSandboxRunEvents: vi.fn(async request => { + sandboxCursors.push(request.cursor); + return new ListSandboxRunEventsResponse({ events: [new RunEvent({ id: `sandbox-${request.cursor || '1'}`, runId: 'run' })], nextCursor: request.cursor ? '' : 'sandbox-2', historyAvailableRunIds: ['run'] }); + }), + listRunEvents: vi.fn(async request => { + runCursors.push(request.cursor); + return new ListRunEventsResponse({ events: [new RunEvent({ id: `run-${request.cursor || '1'}`, runId: 'run' })], nextCursor: request.cursor ? '' : 'run-2', historyAvailable: true }); + }), + }); + + const result = await loadFullSchedulerExecution(input, deps); + expect(schedulerCursors).toEqual(['', 'scheduler-2']); + expect(sandboxCursors).toEqual(['', 'sandbox-2']); + expect(runCursors).toEqual(['', 'run-2']); + expect(result.entries.filter(entry => entry.sourceType === 'run-event')).toHaveLength(4); + expect(result.complete).toBe(true); + }); + + test('marks repeated cursors failed and false history availability unavailable', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"sandboxId":"box","runId":"run"}' })] })), + listSandboxRunEvents: vi.fn(async () => new ListSandboxRunEventsResponse({ nextCursor: 'same' })), + listRunEvents: vi.fn(async () => new ListRunEventsResponse({ historyAvailable: false })), + }); + const result = await loadFullSchedulerExecution(input, deps); + expect(result.sourceStatuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: 'sandbox-run-events', resourceId: 'box', state: 'failed', error: expect.stringContaining('repeated cursor') }), + expect.objectContaining({ source: 'run-events', resourceId: 'run', state: 'unavailable' }), + ])); + expect(result.complete).toBe(false); + }); + + test('keeps run event history unavailable when an early page reports false', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"runId":"run"}' })] })), + listRunEvents: vi.fn(async request => request.cursor + ? new ListRunEventsResponse({ events: [new RunEvent({ id: 'second', runId: 'run' })], historyAvailable: true }) + : new ListRunEventsResponse({ events: [new RunEvent({ id: 'first', runId: 'run' })], nextCursor: 'next', historyAvailable: false })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + expect(result.entries.filter(entry => entry.sourceType === 'run-event').map(entry => entry.sourceId)).toEqual(['first', 'second']); + expect(result.sourceStatuses).toContainEqual(expect.objectContaining({ source: 'run-events', resourceId: 'run', state: 'unavailable' })); + expect(result.complete).toBe(false); + }); + + test('marks sandbox run history unavailable when a relevant linked run is never confirmed', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"sandboxId":"box","runId":"run"}' })] })), + listSandboxRunEvents: vi.fn(async request => request.cursor + ? new ListSandboxRunEventsResponse({ events: [new RunEvent({ id: 'second', runId: 'run' })] }) + : new ListSandboxRunEventsResponse({ events: [new RunEvent({ id: 'first', runId: 'run' })], nextCursor: 'next' })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + expect(result.entries.filter(entry => entry.sourceType === 'run-event').map(entry => entry.sourceId)).toEqual(expect.arrayContaining(['first', 'second'])); + expect(result.sourceStatuses).toContainEqual(expect.objectContaining({ + source: 'sandbox-run-events', resourceId: 'box', state: 'unavailable', error: expect.stringContaining('run'), + })); + expect(result.complete).toBe(false); + }); + + test('accumulates sandbox run history confirmations across pages', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"links":[{"sandboxId":"box","runId":"run-a"},{"sandboxId":"box","runId":"run-b"}]}' })] })), + listSandboxRunEvents: vi.fn(async request => request.cursor + ? new ListSandboxRunEventsResponse({ historyAvailableRunIds: ['run-b'] }) + : new ListSandboxRunEventsResponse({ nextCursor: 'next', historyAvailableRunIds: ['run-a'] })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + expect(result.sourceStatuses).toContainEqual(expect.objectContaining({ source: 'sandbox-run-events', resourceId: 'box', state: 'complete', error: '' })); + expect(result.complete).toBe(true); + }); + + test('uses exact structural sandbox and run pairs for filtering and completeness', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ + id: 'start', runId: 'scheduler-run', type: 'loader.run.started', + payloadJson: '{"links":[{"sandboxId":"box-a","runId":"run-a"},{"sandboxId":"box-b","runId":"run-b"},{"runId":"run-unpaired"}]}', + })] })), + listSandboxRunEvents: vi.fn(async request => new ListSandboxRunEventsResponse({ + events: [ + new RunEvent({ id: `${request.sandboxId}-a`, runId: 'run-a' }), + new RunEvent({ id: `${request.sandboxId}-b`, runId: 'run-b' }), + ], + historyAvailableRunIds: [request.sandboxId === 'box-a' ? 'run-a' : 'run-b'], + })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + const sandboxEvents = result.entries.filter(entry => entry.sourceType === 'run-event' && entry.parentSourceIds.some(id => id.startsWith('sandbox:'))); + expect(sandboxEvents.map(entry => [entry.sourceId, entry.parentSourceIds])).toEqual([ + ['box-a-a', ['sandbox:box-a', 'run-detail:run-a']], + ['box-b-b', ['sandbox:box-b', 'run-detail:run-b']], + ]); + expect(result.sourceStatuses.filter(status => status.source === 'sandbox-run-events')).toEqual([ + { source: 'sandbox-run-events', resourceId: 'box-a', state: 'complete', error: '' }, + { source: 'sandbox-run-events', resourceId: 'box-b', state: 'complete', error: '' }, + ]); + expect(vi.mocked(deps.getRun).mock.calls.map(([request]) => request.runId).sort()).toEqual(['run-a', 'run-b', 'run-unpaired']); + expect(result.complete).toBe(true); + }); + + test('retains only explicitly linked cells and runs', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"sandboxId":"box","cellId":"wanted-cell","runId":"wanted-run"}' })] })), + listSandboxHistory: vi.fn(async () => new ListSandboxHistoryResponse({ cells: [new SandboxHistoryCell({ id: 'wanted-cell' }), new SandboxHistoryCell({ id: 'other-cell' })] })), + listSandboxRunEvents: vi.fn(async () => new ListSandboxRunEventsResponse({ events: [new RunEvent({ id: 'wanted-event', runId: 'wanted-run' }), new RunEvent({ id: 'other-event', runId: 'other-run' })], historyAvailableRunIds: ['wanted-run'] })), + }); + const result = await loadFullSchedulerExecution(input, deps); + expect(result.entries.filter(entry => entry.sourceType === 'cell').map(entry => entry.sourceId)).toEqual(['wanted-cell']); + expect(result.entries.filter(entry => entry.sourceType === 'run-event').map(entry => entry.sourceId)).not.toContain('other-event'); + expect(deps.getRun).toHaveBeenCalledTimes(1); + expect(vi.mocked(deps.getRun).mock.calls[0][0].runId).toBe('wanted-run'); + }); + + test('loads the stable scheduler Project Run only when agent activity is explicit', async () => { + const stableId = await stableProjectRunId('project', 'agent', 'scheduler', 'scheduler-run:agent:1'); + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started' }), + new SchedulerEvent({ id: 'agent-start', runId: 'scheduler-run', type: 'loader.agent.started' }), + ] })), + getRun: vi.fn(async request => new GetRunResponse({ run: new RunDetail({ prompt: request.runId }) })), + listRunEvents: vi.fn(async request => new ListRunEventsResponse({ + events: [new RunEvent({ id: 'stable-event', runId: request.runId })], historyAvailable: true, + })), + followRunLogs: vi.fn(request => chunks(new RunLogChunk({ data: request.runId, isFinal: true }))), + }); + + const result = await loadFullSchedulerExecution(input, deps); + + expect(vi.mocked(deps.getRun).mock.calls.map(([request]) => request.runId)).toEqual([stableId]); + expect(vi.mocked(deps.listRunEvents).mock.calls.map(([request]) => request.runId)).toEqual([stableId]); + expect(vi.mocked(deps.followRunLogs).mock.calls.map(([request]) => request.runId)).toEqual([stableId]); + expect(result.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: `run-detail:${stableId}`, parentSourceIds: ['scheduler-event:agent-start'] }), + expect.objectContaining({ sourceId: 'stable-event' }), + expect.objectContaining({ id: `run-log:${stableId}:0` }), + ])); + expect(result.sourceStatuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: 'run-detail', resourceId: stableId, state: 'complete' }), + expect.objectContaining({ source: 'run-events', resourceId: stableId, state: 'complete' }), + expect.objectContaining({ source: 'run-log', resourceId: stableId, state: 'complete' }), + ])); + expect(result.complete).toBe(true); + }); + + test('does not probe a stable Project Run for scheduler executions without agent activity', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started' }), + new SchedulerEvent({ id: 'shell', runId: 'scheduler-run', type: 'loader.command.completed' }), + new SchedulerEvent({ id: 'llm', runId: 'scheduler-run', type: 'loader.llm.completed' }), + ] })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + + expect(deps.getRun).not.toHaveBeenCalled(); + expect(deps.listRunEvents).not.toHaveBeenCalled(); + expect(deps.followRunLogs).not.toHaveBeenCalled(); + expect(result.sourceStatuses).toEqual(expect.arrayContaining([ + { source: 'run-detail', resourceId: '', state: 'not-applicable', error: '' }, + { source: 'run-events', resourceId: '', state: 'not-applicable', error: '' }, + { source: 'run-log', resourceId: '', state: 'not-applicable', error: '' }, + ])); + expect(result.complete).toBe(true); + }); + + test('filters each sandbox history by structural cell pairs and preserves introducers', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ + id: 'pair-event', runId: 'scheduler-run', type: 'loader.run.started', + payloadJson: JSON.stringify({ links: [ + { sandboxId: 'box-a', cellId: 'shared-cell' }, + { sandboxId: 'box-b', cellId: 'cell-b' }, + { cellId: 'unpaired-cell' }, + ] }), + })] })), + listSandboxHistory: vi.fn(async () => new ListSandboxHistoryResponse({ cells: [ + new SandboxHistoryCell({ id: 'shared-cell' }), + new SandboxHistoryCell({ id: 'cell-b' }), + new SandboxHistoryCell({ id: 'unpaired-cell' }), + ] })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + const cells = result.entries.filter(entry => entry.sourceType === 'cell'); + + expect(cells.map(entry => [entry.sourceId, entry.parentSourceIds])).toEqual([ + ['shared-cell', ['sandbox:box-a', 'scheduler-event:pair-event']], + ['cell-b', ['sandbox:box-b', 'scheduler-event:pair-event']], + ]); + }); + + test('deduplicates a payload run ID that equals the derived stable run ID', async () => { + const stableId = await stableProjectRunId('project', 'agent', 'scheduler', 'scheduler-run:agent:1'); + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: JSON.stringify({ runId: stableId }) }), + new SchedulerEvent({ id: 'agent', runId: 'scheduler-run', type: 'loader.agent.completed' }), + ] })), + }); + + const result = await loadFullSchedulerExecution(input, deps); + expect(deps.getRun).toHaveBeenCalledTimes(1); + expect(result.entries.find(entry => entry.id === `run-detail:${stableId}`)?.parentSourceIds).toEqual([ + 'scheduler-event:start', 'scheduler-event:agent', + ]); + }); + + test('preserves successful resources when one resource fails and requires final log chunks', async () => { + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"runId":"good"}\n{"runId":"bad"}' })] })), + }); + // Use two valid scheduler payloads because malformed JSON is deliberately only a warning. + vi.mocked(deps.listSchedulerEvents).mockResolvedValue(new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: '{"runId":"good"}' }), + new SchedulerEvent({ id: 'link', runId: 'scheduler-run', payloadJson: '{"runId":"bad"}' }), + ] })); + vi.mocked(deps.getRun).mockImplementation(async request => { + if (request.runId === 'bad') throw new Error('detail offline'); + return new GetRunResponse({ run: new RunDetail() }); + }); + vi.mocked(deps.followRunLogs).mockImplementation(request => request.runId === 'good' + ? chunks(new RunLogChunk({ data: 'partial', offset: 1n })) + : chunks(new RunLogChunk({ isFinal: true }))); + + const result = await loadFullSchedulerExecution(input, deps); + expect(result.entries.some(entry => entry.id === 'run-detail:good')).toBe(true); + expect(result.entries.some(entry => entry.id === 'run-log:good:1')).toBe(true); + expect(result.sourceStatuses).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: 'run-detail', resourceId: 'bad', state: 'failed' }), + expect.objectContaining({ source: 'run-log', resourceId: 'good', state: 'unavailable' }), + ])); + }); + + test('keeps persisted run conversation and logs when the linked sandbox was removed', async () => { + const removed = new ConnectError('read /data/sessions/box-gone/metadata.json: no such file', Code.NotFound); + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ + id: 'start', runId: 'scheduler-run', type: 'loader.run.started', + payloadJson: '{"sandboxId":"box-gone","runId":"run-kept"}', + })] })), + getSandbox: vi.fn(async () => { throw removed; }), + listSandboxHistory: vi.fn(async () => { throw removed; }), + getRun: vi.fn(async () => new GetRunResponse({ run: new RunDetail({ prompt: '保留的提问', output: '保留的回答' }) })), + listRunEvents: vi.fn(async () => new ListRunEventsResponse({ + events: [new RunEvent({ id: 'kept-message', runId: 'run-kept', kind: RunEventKind.AGENT_MESSAGE, text: '结构化对话仍然存在' })], + historyAvailable: true, + })), + followRunLogs: vi.fn(() => chunks(new RunLogChunk({ data: '持久化运行日志', isFinal: true }))), + }); + + const result = await loadFullSchedulerExecution(input, deps); + + expect(result.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'run-detail:run-kept', content: expect.stringContaining('保留的回答') }), + expect.objectContaining({ sourceId: 'kept-message', content: '结构化对话仍然存在' }), + expect.objectContaining({ id: 'run-log:run-kept:0', content: '持久化运行日志' }), + ])); + expect(result.sourceStatuses).toEqual(expect.arrayContaining([ + { source: 'sandbox-detail', resourceId: 'box-gone', state: 'unavailable', error: 'Sandbox 已被清理,详情不可用' }, + { source: 'sandbox-history', resourceId: 'box-gone', state: 'unavailable', error: 'Sandbox 已被清理,单元历史不可用' }, + ])); + expect(result.sourceStatuses.map(status => status.error).join('\n')).not.toContain('/data/sessions/box-gone/metadata.json'); + }); + + test('malformed payloads create warning statuses', async () => { + const deps = dependencies({ listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started' }), + new SchedulerEvent({ id: 'bad-json', runId: 'scheduler-run', payloadJson: '{' }), + ] })) }); + const result = await loadFullSchedulerExecution(input, deps); + expect(result.sourceStatuses).toContainEqual(expect.objectContaining({ source: 'scheduler-link', resourceId: 'bad-json', state: 'unavailable' })); + expect(result.complete).toBe(false); + }); + + test('abort rejects with AbortError and stops scheduling queued resources', async () => { + const controller = new AbortController(); + let calls = 0; + const deps = dependencies({ + listSchedulerEvents: vi.fn(async () => new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'scheduler-run', type: 'loader.run.started', payloadJson: JSON.stringify({ runId: ['ignored'], links: Array.from({ length: 8 }, (_, i) => ({ runId: `run-${i}` })) }) })] })), + getRun: vi.fn(async () => { + calls += 1; + if (calls === 1) controller.abort(); + return new GetRunResponse({ run: new RunDetail() }); + }), + }); + await expect(loadFullSchedulerExecution({ ...input, signal: controller.signal }, deps)).rejects.toMatchObject({ name: 'AbortError' }); + expect(calls).toBeLessThanOrEqual(4); + }); +}); diff --git a/src/lib/scheduler-full-execution.ts b/src/lib/scheduler-full-execution.ts new file mode 100644 index 0000000..ddd1acd --- /dev/null +++ b/src/lib/scheduler-full-execution.ts @@ -0,0 +1,278 @@ +import { + FollowRunLogsRequest, GetRunRequest, GetSandboxRequest, ListRunEventsRequest, + ListSandboxHistoryRequest, ListSandboxRunEventsRequest, ListSchedulerEventsRequest, + type GetRunResponse, type GetSandboxResponse, type ListRunEventsResponse, + type ListSandboxHistoryResponse, type ListSandboxRunEventsResponse, + type ListSchedulerEventsResponse, type RunLogChunk, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { groupSchedulerExecutions, type SchedulerOwnedExecution } from './agent-owned-executions'; +import { extractSchedulerExecutionLinks, type LinkedResourceId } from './scheduler-execution-links'; +import { + buildFullExecutionTimeline, + type FullExecutionTimelineEntry, + type SchedulerExecutionRawData, +} from './scheduler-full-timeline'; +import { loadSchedulerRunEvents } from './scheduler-run-timeline'; +import { stableProjectRunId } from './run-scheduler-evidence'; + +export interface FullExecutionDependencies { + listSchedulerEvents(request: ListSchedulerEventsRequest, options?: { signal?: AbortSignal }): Promise; + getSandbox(request: GetSandboxRequest, options?: { signal?: AbortSignal }): Promise; + listSandboxHistory(request: ListSandboxHistoryRequest, options?: { signal?: AbortSignal }): Promise; + listSandboxRunEvents(request: ListSandboxRunEventsRequest, options?: { signal?: AbortSignal }): Promise; + getRun(request: GetRunRequest, options?: { signal?: AbortSignal }): Promise; + listRunEvents(request: ListRunEventsRequest, options?: { signal?: AbortSignal }): Promise; + followRunLogs(request: FollowRunLogsRequest, options?: { signal?: AbortSignal }): AsyncIterable; +} + +export interface FullSchedulerExecutionInput { + projectId: string; + agentName: string; + schedulerRunId: string; + signal?: AbortSignal; +} + +export type SourceCompletenessState = 'complete' | 'unavailable' | 'failed' | 'not-applicable'; +export interface SourceCompleteness { + source: string; + resourceId: string; + state: SourceCompletenessState; + error: string; +} +export type AggregationPhase = 'scheduler' | 'discovering' | 'sandbox' | 'run' | 'merging'; +export interface FullSchedulerExecutionResult { + execution?: SchedulerOwnedExecution; + sandboxIds: string[]; + entries: FullExecutionTimelineEntry[]; + sourceStatuses: SourceCompleteness[]; + complete: boolean; + snapshotAt: string; +} + +function abortError(): DOMException { + return new DOMException('The operation was aborted', 'AbortError'); +} + +function checkAbort(signal?: AbortSignal): void { + if (signal?.aborted) throw abortError(); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// sandbox/run 结束后底层资源可能已被清理,后端对这些资源返回 NotFound(如 read session metadata ... no such file)。 +// 这属于正常终态而非加载失败,标记为不可用并给出友好提示,避免把原始 [not_found] 错误透出到界面。 +function classifySourceError(error: unknown, notFoundHint: string): { state: SourceCompletenessState; error: string } { + if (ConnectError.from(error).code === Code.NotFound) { + return { state: 'unavailable', error: notFoundHint }; + } + return { state: 'failed', error: errorMessage(error) }; +} + +async function exhaustCursor( + source: string, + fetchPage: (cursor: string) => Promise<{ values: T[]; nextCursor: string; available?: boolean }>, +): Promise<{ values: T[]; available: boolean }> { + const values: T[] = []; + const seen = new Set(); + let available = true; + let cursor = ''; + while (true) { + const page = await fetchPage(cursor); + values.push(...page.values); + available = available && page.available !== false; + if (!page.nextCursor) return { values, available }; + if (seen.has(page.nextCursor)) throw new Error(`${source} returned repeated cursor`); + seen.add(page.nextCursor); + cursor = page.nextCursor; + } +} + +async function runPool(tasks: Array<() => Promise>, signal?: AbortSignal): Promise { + let next = 0; + async function worker(): Promise { + while (true) { + checkAbort(signal); + const index = next++; + if (index >= tasks.length) return; + await tasks[index](); + } + } + await Promise.all(Array.from({ length: Math.min(4, tasks.length) }, worker)); +} + +function parents(link: Pick): string[] { + return link.introducedBy.map(id => `scheduler-event:${id}`); +} + +export async function loadFullSchedulerExecution( + input: FullSchedulerExecutionInput, + dependencies: FullExecutionDependencies, + onProgress: (phase: AggregationPhase) => void = () => {}, +): Promise { + const { signal } = input; + onProgress('scheduler'); + checkAbort(signal); + const schedulerEvents = await loadSchedulerRunEvents(input.schedulerRunId, request => { + checkAbort(signal); + return dependencies.listSchedulerEvents(new ListSchedulerEventsRequest({ + ...request, project: { projectId: input.projectId }, agentName: input.agentName, + }), { signal }); + }); + checkAbort(signal); + const execution = groupSchedulerExecutions(schedulerEvents).find(value => value.schedulerRunId === input.schedulerRunId); + const statuses: SourceCompleteness[] = [{ + source: 'scheduler-events', resourceId: input.schedulerRunId, + state: execution ? 'complete' : 'not-applicable', error: execution ? '' : 'Scheduler execution was not found', + }]; + const raw: SchedulerExecutionRawData = { + schedulerEvents, sandboxes: [], cells: [], runDetails: [], runEvents: [], runLogs: [], sourceStatuses: statuses, + }; + + if (!execution) { + onProgress('merging'); + return { execution: undefined, sandboxIds: [], entries: buildFullExecutionTimeline(raw), sourceStatuses: statuses, complete: false, snapshotAt: new Date().toISOString() }; + } + + onProgress('discovering'); + const links = extractSchedulerExecutionLinks(schedulerEvents); + const sandboxIds = new Set(links.sandboxes.map(link => link.id)); + for (const warning of links.warnings) statuses.push({ + source: 'scheduler-link', resourceId: warning.eventId, state: 'unavailable', error: warning.message, + }); + const runLinks = links.runs.map(link => ({ ...link, introducedBy: [...link.introducedBy] })); + const agentEventIds = schedulerEvents.filter(event => event.type.startsWith('loader.agent.')).map(event => event.id); + if (agentEventIds.length) { + const terminalAgentRunCount = schedulerEvents.filter(event => event.type === 'loader.agent.completed' || event.type === 'loader.agent.failed').length; + for (let sequence = 1; sequence <= Math.max(1, terminalAgentRunCount); sequence++) { + const clientRequestId = `${input.schedulerRunId}:agent:${sequence}`; + const stableRunId = await stableProjectRunId(input.projectId, input.agentName, 'scheduler', clientRequestId); + const existing = runLinks.find(link => link.id === stableRunId); + if (existing) existing.introducedBy = [...new Set([...existing.introducedBy, ...agentEventIds])]; + else runLinks.push({ id: stableRunId, introducedBy: agentEventIds }); + } + } + runLinks.sort((left, right) => left.id.localeCompare(right.id)); + if (!links.sandboxes.length) { + for (const source of ['sandbox-detail', 'sandbox-history', 'sandbox-run-events']) { + statuses.push({ source, resourceId: '', state: 'not-applicable', error: '' }); + } + } + if (!runLinks.length) { + for (const source of ['run-detail', 'run-events', 'run-log']) { + statuses.push({ source, resourceId: '', state: 'not-applicable', error: '' }); + } + } + + onProgress('sandbox'); + await runPool(links.sandboxes.map(link => async () => { + const sandboxParents = parents(link); + const relevantRunIds = links.sandboxRuns + .filter(pair => pair.sandboxId === link.id) + .map(pair => pair.runId); + const relevantRunIdSet = new Set(relevantRunIds); + const relevantCellParents = new Map( + links.sandboxCells.filter(pair => pair.sandboxId === link.id).map(pair => [pair.cellId, parents(pair)]), + ); + try { + checkAbort(signal); + const response = await dependencies.getSandbox(new GetSandboxRequest({ sandboxId: link.id }), { signal }); + if (response.sandbox) raw.sandboxes.push({ sandboxId: link.id, value: response.sandbox, parentSourceIds: sandboxParents }); + statuses.push({ source: 'sandbox-detail', resourceId: link.id, state: response.sandbox ? 'complete' : 'unavailable', error: response.sandbox ? '' : 'Sandbox detail was not returned' }); + } catch (error) { + if (signal?.aborted) throw abortError(); + statuses.push({ source: 'sandbox-detail', resourceId: link.id, ...classifySourceError(error, 'Sandbox 已被清理,详情不可用') }); + } + try { + checkAbort(signal); + const history = await dependencies.listSandboxHistory(new ListSandboxHistoryRequest({ sandboxId: link.id }), { signal }); + for (const cell of history.cells) { + const cellParents = relevantCellParents.get(cell.id); + if (cellParents) raw.cells.push({ + sandboxId: link.id, + cellId: cell.id, + value: cell, + parentSourceIds: [...new Set([`sandbox:${link.id}`, ...sandboxParents, ...cellParents])], + }); + } + statuses.push({ source: 'sandbox-history', resourceId: link.id, state: 'complete', error: '' }); + } catch (error) { + if (signal?.aborted) throw abortError(); + statuses.push({ source: 'sandbox-history', resourceId: link.id, ...classifySourceError(error, 'Sandbox 已被清理,单元历史不可用') }); + } + try { + const availableRunIds = new Set(); + const result = await exhaustCursor(`sandbox-run-events:${link.id}`, async cursor => { + checkAbort(signal); + const page = await dependencies.listSandboxRunEvents(new ListSandboxRunEventsRequest({ sandboxId: link.id, limit: 500, cursor }), { signal }); + for (const runId of page.historyAvailableRunIds) availableRunIds.add(runId); + return { values: page.events, nextCursor: page.nextCursor }; + }); + for (const event of result.values) if (relevantRunIdSet.has(event.runId)) raw.runEvents.push({ runId: event.runId, value: event, parentSourceIds: [`sandbox:${link.id}`, `run-detail:${event.runId}`] }); + const unavailableRunIds = relevantRunIds.filter(runId => !availableRunIds.has(runId)); + statuses.push({ + source: 'sandbox-run-events', resourceId: link.id, + state: unavailableRunIds.length ? 'unavailable' : 'complete', + error: unavailableRunIds.length ? `Run event history is unavailable for: ${unavailableRunIds.join(', ')}` : '', + }); + } catch (error) { + if (signal?.aborted) throw abortError(); + statuses.push({ source: 'sandbox-run-events', resourceId: link.id, ...classifySourceError(error, 'Sandbox 已被清理,Run 事件不可用') }); + } + }), signal); + + onProgress('run'); + await runPool(runLinks.map(link => async () => { + const runParents = parents(link); + try { + checkAbort(signal); + const response = await dependencies.getRun(new GetRunRequest({ projectId: input.projectId, runId: link.id }), { signal }); + if (response.run) { + raw.runDetails.push({ runId: link.id, value: response.run, parentSourceIds: runParents }); + const sandboxId = response.run.summary?.sandboxId.trim(); + if (sandboxId) sandboxIds.add(sandboxId); + } + statuses.push({ source: 'run-detail', resourceId: link.id, state: response.run ? 'complete' : 'unavailable', error: response.run ? '' : 'Run detail was not returned' }); + } catch (error) { + if (signal?.aborted) throw abortError(); + statuses.push({ source: 'run-detail', resourceId: link.id, ...classifySourceError(error, '运行记录不存在或已被清理') }); + } + try { + const result = await exhaustCursor(`run-events:${link.id}`, async cursor => { + checkAbort(signal); + const page = await dependencies.listRunEvents(new ListRunEventsRequest({ runId: link.id, limit: 500, cursor }), { signal }); + return { values: page.events, nextCursor: page.nextCursor, available: page.historyAvailable }; + }); + for (const event of result.values) if (event.runId === link.id) raw.runEvents.push({ runId: link.id, value: event, parentSourceIds: [`run-detail:${link.id}`] }); + statuses.push({ source: 'run-events', resourceId: link.id, state: result.available ? 'complete' : 'unavailable', error: result.available ? '' : 'Run event history is unavailable' }); + } catch (error) { + if (signal?.aborted) throw abortError(); + statuses.push({ source: 'run-events', resourceId: link.id, ...classifySourceError(error, '运行事件记录不存在') }); + } + try { + checkAbort(signal); + let final = false; + const stream = dependencies.followRunLogs(new FollowRunLogsRequest({ projectId: input.projectId, runId: link.id, startOffset: 0n, follow: false }), { signal }); + for await (const chunk of stream) { + checkAbort(signal); + raw.runLogs.push({ runId: link.id, offset: chunk.offset, data: chunk.data, createdAt: chunk.createdAt, parentSourceIds: [`run-detail:${link.id}`] }); + if (chunk.isFinal) { final = true; break; } + } + statuses.push({ source: 'run-log', resourceId: link.id, state: final ? 'complete' : 'unavailable', error: final ? '' : 'Run log stream ended before a final chunk' }); + } catch (error) { + if (signal?.aborted) throw abortError(); + statuses.push({ source: 'run-log', resourceId: link.id, ...classifySourceError(error, '运行日志不可用') }); + } + }), signal); + + checkAbort(signal); + onProgress('merging'); + const entries = buildFullExecutionTimeline(raw); + return { + execution, sandboxIds: [...sandboxIds].sort(), entries, sourceStatuses: statuses, + complete: statuses.every(status => status.state === 'complete' || status.state === 'not-applicable'), + snapshotAt: new Date().toISOString(), + }; +} diff --git a/src/lib/scheduler-full-timeline.test.ts b/src/lib/scheduler-full-timeline.test.ts new file mode 100644 index 0000000..0344525 --- /dev/null +++ b/src/lib/scheduler-full-timeline.test.ts @@ -0,0 +1,97 @@ +import { Timestamp } from '@bufbuild/protobuf'; +import { describe, expect, test } from 'vitest'; +import { + RunDetail, + RunEvent, + RunEventKind, + RunSummary, + SandboxHistoryCell, + SchedulerEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { buildFullExecutionTimeline, type SchedulerExecutionRawData } from './scheduler-full-timeline'; + +function time(seconds: bigint): Timestamp { + return new Timestamp({ seconds }); +} + +function fixture(values: Partial): SchedulerExecutionRawData { + return { + schedulerEvents: [], sandboxes: [], cells: [], runDetails: [], runEvents: [], runLogs: [], sourceStatuses: [], + ...values, + }; +} + +describe('buildFullExecutionTimeline', () => { + test('keeps distinct repeated output, deduplicates stable IDs, and puts unknown times last', () => { + const scheduler = new SchedulerEvent({ id: 's1', createdAt: time(2n), message: 'same' }); + const entries = buildFullExecutionTimeline(fixture({ + schedulerEvents: [scheduler, scheduler], + runEvents: [ + { runId: 'run', value: new RunEvent({ id: 'r1', seq: 1n, createdAt: time(1n), text: 'same' }), parentSourceIds: [] }, + { runId: 'run', value: new RunEvent({ id: 'r2', seq: 2n, createdAt: time(1n), text: 'same' }), parentSourceIds: [] }, + ], + cells: [{ + sandboxId: 'box', cellId: 'cell-1', + value: new SandboxHistoryCell({ id: 'cell-1', output: 'complete output' }), parentSourceIds: [], + }], + })); + + expect(entries.map(entry => entry.id)).toEqual([ + 'run-event:r1', 'run-event:r2', 'scheduler-event:s1', 'cell:box:cell-1', + ]); + expect(entries.at(-1)?.timestamp).toBe(''); + expect(entries.at(-1)?.content).toContain('complete output'); + }); + + test('normalizes every source, preserves generated JSON, and classifies filters', () => { + const entries = buildFullExecutionTimeline(fixture({ + schedulerEvents: [new SchedulerEvent({ id: 'scheduler', type: 'loader.run.failed', level: 'error', message: 'failed', createdAt: time(1n) })], + sandboxes: [{ sandboxId: 'box', value: { id: 'box', updatedAt: '2024-01-01T00:00:02Z', state: 'running', revision: 9n }, parentSourceIds: ['scheduler-event:scheduler'] }], + cells: [{ sandboxId: 'box', cellId: 'cell', value: new SandboxHistoryCell({ id: 'cell', source: 'prompt', stdout: 'out', stderr: 'warn', output: 'answer', createdAt: time(3n) }), parentSourceIds: ['sandbox:box'] }], + runDetails: [{ runId: 'run', value: new RunDetail({ summary: new RunSummary({ runId: 'run', createdAt: '2024-01-01T00:00:04Z', error: 'boom' }), prompt: 'question', output: 'answer', resultJson: '{"artifact":true}' }), parentSourceIds: ['scheduler-event:scheduler'] }], + runEvents: [{ runId: 'run', value: new RunEvent({ id: 'event', kind: RunEventKind.AGENT_MESSAGE, seq: 5n, text: 'message', createdAt: time(5n) }), parentSourceIds: ['run-detail:run'] }], + runLogs: [{ runId: 'run', offset: 6n, data: 'log data', createdAt: '2024-01-01T00:00:06Z', parentSourceIds: ['run-detail:run'] }], + sourceStatuses: [{ source: 'run-log', resourceId: 'missing', state: 'failed', error: 'network problem' }], + })); + + expect(entries.map(entry => entry.sourceType)).toEqual([ + 'scheduler-event', 'cell', 'run-event', 'sandbox', 'run-detail', 'run-log', 'source-status', + ]); + expect(entries.find(entry => entry.id === 'cell:box:cell')?.raw).toContain('"stdout": "out"'); + expect(entries.find(entry => entry.id === 'sandbox:box')?.raw).toContain('"revision": "9"'); + expect(entries.find(entry => entry.id === 'run-event:event')?.raw).toContain('"seq": "5"'); + expect(entries.find(entry => entry.id === 'run-log:run:6')?.content).toBe('log data'); + expect(entries.find(entry => entry.id === 'run-detail:run')?.filterTags).toEqual(expect.arrayContaining(['run', 'message', 'artifact', 'problem'])); + expect(entries.find(entry => entry.id === 'cell:box:cell')?.filterTags).toEqual(expect.arrayContaining(['run', 'message', 'activity', 'artifact'])); + expect(entries.find(entry => entry.id === 'source-status:run-log:missing')?.filterTags).toEqual(expect.arrayContaining(['problem'])); + }); + + test('derives deterministic identities for missing IDs and sorts invalid dates last', () => { + const first = { runId: 'run', value: new RunEvent({ seq: 7n, text: 'same' }), parentSourceIds: ['run:parent-a'] }; + const second = { runId: 'run', value: new RunEvent({ seq: 7n, text: 'same' }), parentSourceIds: ['run:parent-b'] }; + const entries = buildFullExecutionTimeline(fixture({ + schedulerEvents: [new SchedulerEvent({ id: 'known', createdAt: time(1n) })], + runEvents: [second, first], + runLogs: [{ runId: 'run', offset: 0n, data: 'invalid time', createdAt: 'not-a-date', parentSourceIds: [] }], + })); + + expect(entries).toHaveLength(4); + expect(entries[0].id).toBe('scheduler-event:known'); + const generatedIds = entries.filter(entry => entry.sourceType === 'run-event').map(entry => entry.id); + expect(generatedIds[0]).not.toBe(generatedIds[1]); + expect(generatedIds[0] < generatedIds[1]).toBe(true); + expect(entries.find(entry => entry.sourceType === 'run-log')?.timestamp).toBe(''); + }); + + test('normalizes an out-of-range protobuf timestamp as unknown without throwing', () => { + const entries = buildFullExecutionTimeline(fixture({ + schedulerEvents: [ + new SchedulerEvent({ id: 'known', createdAt: time(1n) }), + new SchedulerEvent({ id: 'malformed', createdAt: time(8_640_000_000_001n) }), + ], + })); + + expect(entries.map(entry => entry.id)).toEqual(['scheduler-event:known', 'scheduler-event:malformed']); + expect(entries[1].timestamp).toBe(''); + }); +}); diff --git a/src/lib/scheduler-full-timeline.ts b/src/lib/scheduler-full-timeline.ts new file mode 100644 index 0000000..a07e254 --- /dev/null +++ b/src/lib/scheduler-full-timeline.ts @@ -0,0 +1,243 @@ +import { + RunEventKind, + type RunDetail, + type RunEvent, + type SandboxHistoryCell, + type SchedulerEvent, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import type { + RuntimeTimelineEntry, + RuntimeTimelineFilterTag, + RuntimeTimelineKind, +} from './runtime-timeline'; + +export type FullExecutionSource = 'scheduler-event' | 'sandbox' | 'cell' | 'run-detail' | 'run-event' | 'run-log' | 'source-status'; + +export interface FullExecutionTimelineEntry extends RuntimeTimelineEntry { + sourceType: FullExecutionSource; + sourceId: string; + parentSourceIds: string[]; + raw: string; +} + +export type SourceCompletenessState = 'complete' | 'unavailable' | 'failed' | 'not-applicable'; + +export interface SourceCompleteness { + source: string; + resourceId: string; + state: SourceCompletenessState; + error: string; +} + +export interface SchedulerExecutionRawData { + schedulerEvents: SchedulerEvent[]; + sandboxes: Array<{ sandboxId: string; value: unknown; parentSourceIds: string[] }>; + cells: Array<{ sandboxId: string; cellId: string; value: SandboxHistoryCell; parentSourceIds: string[] }>; + runDetails: Array<{ runId: string; value: RunDetail; parentSourceIds: string[] }>; + runEvents: Array<{ runId: string; value: RunEvent; parentSourceIds: string[] }>; + runLogs: Array<{ runId: string; offset: bigint; data: string; createdAt: string; parentSourceIds: string[] }>; + sourceStatuses: SourceCompleteness[]; +} + +interface PendingEntry extends Omit { + stableId: string; + sourceSequence: bigint; +} + +function plainValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(plainValue); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, plainValue(child)])); +} + +function jsonValue(value: unknown): unknown { + if (value && typeof value === 'object' && 'toJson' in value && typeof value.toJson === 'function') { + try { + return value.toJson(); + } catch { + return plainValue(value); + } + } + return value; +} + +function serialize(value: unknown): string { + return JSON.stringify(jsonValue(value), (_key, child) => typeof child === 'bigint' ? child.toString() : child, 2) ?? 'null'; +} + +function protobufTimestamp(value: { seconds: bigint; nanos: number } | undefined): string { + if (!value) return ''; + if (value.seconds < -62_135_596_800n || value.seconds > 253_402_300_799n) return ''; + if (!Number.isInteger(value.nanos) || value.nanos < 0 || value.nanos > 999_999_999) return ''; + const milliseconds = Number(value.seconds) * 1000 + value.nanos / 1_000_000; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); +} + +function validTimestamp(value: unknown): string { + if (typeof value !== 'string' || !value || Number.isNaN(Date.parse(value))) return ''; + return value; +} + +function objectTimestamp(value: unknown): string { + if (!value || typeof value !== 'object') return ''; + const record = value as Record; + for (const key of ['createdAt', 'startedAt', 'updatedAt', 'completedAt', 'sampledAt']) { + const timestamp = validTimestamp(record[key]); + if (timestamp) return timestamp; + } + return ''; +} + +function uniqueTags(...tags: Array): RuntimeTimelineFilterTag[] { + return [...new Set(tags.filter((tag): tag is RuntimeTimelineFilterTag => Boolean(tag)))]; +} + +function levelFrom(error: boolean, warning = false): RuntimeTimelineEntry['level'] { + return error ? 'error' : warning ? 'warning' : 'info'; +} + +function derivedSourceId(sourceType: FullExecutionSource, parents: string[], sequence: bigint, raw: string): string { + return `${parents.join(',')}|${sequence}|${raw}`; +} + +function makePending(options: { + sourceType: FullExecutionSource; + sourceId: string; + parentSourceIds: string[]; + raw: string; + timestamp: string; + sourceSequence?: bigint; + kind: RuntimeTimelineKind; + source: string; + level?: RuntimeTimelineEntry['level']; + content: string; + filterTags: RuntimeTimelineFilterTag[]; + offset?: bigint; + identityId?: string; +}): PendingEntry { + const sourceSequence = options.sourceSequence ?? 0n; + const sourceId = options.sourceId || derivedSourceId(options.sourceType, options.parentSourceIds, sourceSequence, options.raw); + return { + stableId: `${options.sourceType}:${options.identityId ?? sourceId}`, + sourceType: options.sourceType, + sourceId, + parentSourceIds: [...options.parentSourceIds], + raw: options.raw, + timestamp: validTimestamp(options.timestamp), + sourceSequence, + kind: options.kind, + source: options.source, + level: options.level ?? 'info', + content: options.content || options.raw, + timestampInferred: false, + filterTags: options.filterTags, + ...(options.offset === undefined ? {} : { offset: options.offset }), + }; +} + +function comparePending(left: PendingEntry, right: PendingEntry): number { + const leftTime = left.timestamp ? Date.parse(left.timestamp) : Number.POSITIVE_INFINITY; + const rightTime = right.timestamp ? Date.parse(right.timestamp) : Number.POSITIVE_INFINITY; + if (leftTime !== rightTime) return leftTime - rightTime; + if (left.sourceSequence !== right.sourceSequence) return left.sourceSequence < right.sourceSequence ? -1 : 1; + return left.stableId < right.stableId ? -1 : left.stableId > right.stableId ? 1 : 0; +} + +export function buildFullExecutionTimeline(data: SchedulerExecutionRawData): FullExecutionTimelineEntry[] { + const pending: PendingEntry[] = []; + + data.schedulerEvents.forEach((event) => { + const raw = serialize(event); + const error = /error|fatal/i.test(event.level) || event.type.endsWith('.failed'); + const warning = /warn/i.test(event.level) || event.type.endsWith('.warning'); + pending.push(makePending({ + sourceType: 'scheduler-event', sourceId: event.id, parentSourceIds: [], raw, + timestamp: protobufTimestamp(event.createdAt), + kind: event.type.startsWith('loader.run.') ? 'run' : event.type.startsWith('loader.sandbox.') ? 'sandbox' : error ? 'error' : 'scheduler', + source: event.type || 'scheduler', level: levelFrom(error, warning), + content: [event.message, event.payloadJson].filter(Boolean).join('\n') || raw, + filterTags: uniqueTags(event.type.startsWith('loader.') ? 'run' : 'activity', Boolean(event.payloadJson) && 'artifact', (error || warning) && 'problem'), + })); + }); + + data.sandboxes.forEach((item) => { + const raw = serialize(item.value); + pending.push(makePending({ + sourceType: 'sandbox', sourceId: item.sandboxId, parentSourceIds: item.parentSourceIds, raw, + timestamp: objectTimestamp(jsonValue(item.value)), kind: 'sandbox', source: 'sandbox', + content: raw, filterTags: ['run'], + })); + }); + + data.cells.forEach((item) => { + const cell = item.value; + const raw = serialize(cell); + const hasProblem = Boolean(cell.stderr) || (!cell.running && !cell.success && cell.exitCode !== 0); + pending.push(makePending({ + sourceType: 'cell', sourceId: item.cellId || cell.id, parentSourceIds: item.parentSourceIds, raw, + identityId: `${item.sandboxId}:${item.cellId || cell.id}`, + timestamp: protobufTimestamp(cell.createdAt), + kind: hasProblem ? 'error' : cell.output ? 'output' : 'sandbox', source: cell.type || 'sandbox cell', + level: levelFrom(hasProblem), content: [cell.source, cell.stdout, cell.stderr, cell.output, cell.stopReason].filter(Boolean).join('\n') || raw, + filterTags: uniqueTags('run', Boolean(cell.source || cell.output) && 'message', 'activity', Boolean(cell.stdout || cell.stderr || cell.output) && 'artifact', hasProblem && 'problem'), + })); + }); + + data.runDetails.forEach((item) => { + const detail = item.value; + const raw = serialize(detail); + const problem = Boolean(detail.summary?.error || detail.cleanupError || detail.warnings.length); + pending.push(makePending({ + sourceType: 'run-detail', sourceId: item.runId, parentSourceIds: item.parentSourceIds, raw, + timestamp: objectTimestamp(detail.summary), kind: problem ? 'error' : 'run', source: 'run detail', + level: levelFrom(Boolean(detail.summary?.error || detail.cleanupError), detail.warnings.length > 0), + content: [detail.prompt, detail.output, detail.resultJson, ...detail.warnings, detail.summary?.error, detail.cleanupError].filter(Boolean).join('\n') || raw, + filterTags: uniqueTags('run', Boolean(detail.prompt || detail.output) && 'message', Boolean(detail.output || detail.resultJson || detail.artifactsDir) && 'artifact', problem && 'problem'), + })); + }); + + data.runEvents.forEach((item) => { + const event = item.value; + const raw = serialize(event); + const message = event.kind === RunEventKind.USER_MESSAGE || event.kind === RunEventKind.AGENT_MESSAGE; + const problem = (event.kind === RunEventKind.STATUS || event.kind === RunEventKind.AGENT_ACTIVITY) && !event.success; + pending.push(makePending({ + sourceType: 'run-event', sourceId: event.id, parentSourceIds: item.parentSourceIds, raw, + timestamp: protobufTimestamp(event.createdAt), sourceSequence: event.seq, kind: problem ? 'error' : message ? 'process' : 'tool', + source: event.name || event.agent || 'run event', level: levelFrom(problem), + content: [event.text, event.payloadJson, event.stopReason].filter(Boolean).join('\n') || raw, + filterTags: uniqueTags('run', message && 'message', !message && 'activity', Boolean(event.payloadJson) && 'artifact', problem && 'problem'), + })); + }); + + data.runLogs.forEach((item) => { + const raw = serialize(item); + pending.push(makePending({ + sourceType: 'run-log', sourceId: `${item.runId}:${item.offset}`, parentSourceIds: item.parentSourceIds, raw, + timestamp: item.createdAt, sourceSequence: item.offset, kind: 'log', source: 'run log', content: item.data, + filterTags: uniqueTags('run', 'activity', 'artifact', /(?:error|fatal|panic)/i.test(item.data) && 'problem'), offset: item.offset, + })); + }); + + data.sourceStatuses.forEach((status) => { + if (status.state === 'not-applicable') return; + const raw = serialize(status); + const problem = status.state === 'failed' || status.state === 'unavailable'; + pending.push(makePending({ + sourceType: 'source-status', sourceId: `${status.source}:${status.resourceId}`, parentSourceIds: [], raw, + timestamp: '', kind: problem ? 'warning' : 'run', source: status.source, + level: levelFrom(status.state === 'failed', status.state === 'unavailable'), + content: [status.state, status.error].filter(Boolean).join('\n'), filterTags: uniqueTags('run', problem && 'problem'), + })); + }); + + const unique = new Map(); + for (const entry of pending) if (!unique.has(entry.stableId)) unique.set(entry.stableId, entry); + return [...unique.values()].sort(comparePending).map(({ stableId, sourceSequence: _sourceSequence, ...entry }, sequence) => ({ + ...entry, + id: stableId, + sortTime: entry.timestamp ? Date.parse(entry.timestamp) : Number.POSITIVE_INFINITY, + sequence, + })); +} diff --git a/src/lib/scheduler-run-event.test.ts b/src/lib/scheduler-run-event.test.ts new file mode 100644 index 0000000..edf5fd4 --- /dev/null +++ b/src/lib/scheduler-run-event.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from 'vitest'; +import { schedulerRunEventId } from './scheduler-run-event'; + +test('reads and trims the triggering Event ID from a Scheduler Run payload', () => { + expect(schedulerRunEventId('{"payload":{"eventId":" evt_123 "}}')).toBe('evt_123'); +}); + +test.each(['', '{', '{}', '{"payload":{}}', '{"payload":{"eventId":42}}'])( + 'returns empty for an unusable Scheduler Run payload: %s', + payload => { + expect(schedulerRunEventId(payload)).toBe(''); + }, +); diff --git a/src/lib/scheduler-run-event.ts b/src/lib/scheduler-run-event.ts new file mode 100644 index 0000000..7a5cce7 --- /dev/null +++ b/src/lib/scheduler-run-event.ts @@ -0,0 +1,8 @@ +export function schedulerRunEventId(payloadJson: string): string { + try { + const value = JSON.parse(payloadJson) as { payload?: { eventId?: unknown } }; + return typeof value.payload?.eventId === 'string' ? value.payload.eventId.trim() : ''; + } catch { + return ''; + } +} diff --git a/src/lib/scheduler-run-timeline.test.ts b/src/lib/scheduler-run-timeline.test.ts new file mode 100644 index 0000000..fb36ab0 --- /dev/null +++ b/src/lib/scheduler-run-timeline.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; +import { ListSchedulerEventsResponse, SchedulerEvent } from '../gen/agentcompose/v2/agentcompose_pb'; +import { buildSchedulerRunTimeline, loadSchedulerRunEvents } from './scheduler-run-timeline'; + +test('loads only the target Scheduler Run through its start event', async () => { + const cursors: string[] = []; + const result = await loadSchedulerRunEvents('loader-1', async request => { + cursors.push(request.cursor); + if (!request.cursor) return new ListSchedulerEventsResponse({ events: [ + new SchedulerEvent({ id: 'done', runId: 'loader-1', type: 'loader.run.completed' }), + new SchedulerEvent({ id: 'other', runId: 'other', type: 'loader.run.started' }), + ], nextCursor: 'next' }); + return new ListSchedulerEventsResponse({ events: [new SchedulerEvent({ id: 'start', runId: 'loader-1', type: 'loader.run.started' })] }); + }); + expect(cursors).toEqual(['', 'next']); + expect(result.map(event => event.id)).toEqual(['start', 'done']); +}); + +test('rejects repeated Scheduler pagination cursors', async () => { + await expect(loadSchedulerRunEvents('missing', async () => new ListSchedulerEventsResponse({ nextCursor: 'same' }))).rejects.toThrow('repeated cursor'); +}); + +describe('buildSchedulerRunTimeline', () => { + test('retains raw event evidence and applies overlapping artifact/problem tags', () => { + const entries = buildSchedulerRunTimeline([ + new SchedulerEvent({ id: 'command', runId: 'loader-1', type: 'loader.command.completed', message: '22 items', payloadJson: '{"output":"result"}', createdAt: { seconds: 2n } }), + new SchedulerEvent({ id: 'warning', runId: 'loader-1', type: 'loader.deprecated_alias.warning', level: 'warning', message: 'deprecated', createdAt: { seconds: 3n } }), + new SchedulerEvent({ id: 'start', runId: 'loader-1', type: 'loader.run.started', createdAt: { seconds: 1n } }), + ]); + expect(entries.map(entry => entry.content)).toEqual(expect.arrayContaining(['loader.run.started', '22 items\n{"output":"result"}', 'deprecated'])); + expect(entries.find(entry => entry.id === 'scheduler-event:command')?.filterTags).toEqual(expect.arrayContaining(['activity', 'artifact'])); + expect(entries.find(entry => entry.id === 'scheduler-event:warning')).toMatchObject({ level: 'warning', filterTags: expect.arrayContaining(['problem']) }); + }); +}); diff --git a/src/lib/scheduler-run-timeline.ts b/src/lib/scheduler-run-timeline.ts new file mode 100644 index 0000000..b7a434b --- /dev/null +++ b/src/lib/scheduler-run-timeline.ts @@ -0,0 +1,71 @@ +import { ListSchedulerEventsRequest, type ListSchedulerEventsResponse, type SchedulerEvent } from '../gen/agentcompose/v2/agentcompose_pb'; +import type { RuntimeTimelineEntry, RuntimeTimelineFilterTag } from './runtime-timeline'; + +export async function loadSchedulerRunEvents( + targetRunId: string, + fetchPage: (request: ListSchedulerEventsRequest) => Promise, +): Promise { + const found: SchedulerEvent[] = []; + const seenCursors = new Set(); + let cursor = ''; + let foundStart = false; + while (true) { + const page = await fetchPage(new ListSchedulerEventsRequest({ limit: 500, cursor })); + for (const event of page.events) { + if (event.runId !== targetRunId) continue; + found.push(event); + if (event.type === 'loader.run.started') foundStart = true; + } + if (foundStart || !page.nextCursor) break; + if (seenCursors.has(page.nextCursor)) throw new Error('Scheduler event pagination returned repeated cursor'); + seenCursors.add(page.nextCursor); + cursor = page.nextCursor; + } + return found.sort(compareEvents); +} + +function timestamp(event: SchedulerEvent): string { + if (!event.createdAt) return ''; + return new Date(Number(event.createdAt.seconds) * 1000 + event.createdAt.nanos / 1_000_000).toISOString(); +} + +function eventRank(event: SchedulerEvent): number { + if (event.type === 'loader.run.started') return -1; + if (['loader.run.completed', 'loader.run.failed', 'loader.run.skipped'].includes(event.type)) return 1; + return 0; +} + +function compareEvents(left: SchedulerEvent, right: SchedulerEvent): number { + return timestamp(left).localeCompare(timestamp(right)) || eventRank(left) - eventRank(right) || left.id.localeCompare(right.id); +} + +function tags(event: SchedulerEvent): RuntimeTimelineFilterTag[] { + const result = new Set(); + if (event.type.startsWith('loader.run.') || event.type.startsWith('loader.sandbox.')) result.add('run'); + else result.add('activity'); + if (/loader\.(?:command|llm|agent|event)\./.test(event.type) && event.payloadJson) result.add('artifact'); + if (/warn|error|fatal/i.test(event.level) || /\.failed$|\.warning$/.test(event.type)) result.add('problem'); + return [...result]; +} + +export function buildSchedulerRunTimeline(events: readonly SchedulerEvent[]): RuntimeTimelineEntry[] { + return [...events] + .sort(compareEvents) + .map((event, sequence) => { + const createdAt = timestamp(event); + const isError = /error|fatal/i.test(event.level) || event.type.endsWith('.failed'); + const isWarning = /warn/i.test(event.level) || event.type.endsWith('.warning'); + return { + id: `scheduler-event:${event.id || sequence}`, + timestamp: createdAt, + sortTime: createdAt ? Date.parse(createdAt) : 0, + sequence, + kind: event.type.startsWith('loader.run.') ? 'run' : event.type.startsWith('loader.sandbox.') ? 'sandbox' : isError ? 'error' : 'process', + source: event.type, + level: isError ? 'error' : isWarning ? 'warning' : 'info', + content: [event.message, event.payloadJson].filter(Boolean).join('\n') || event.type, + timestampInferred: false, + filterTags: tags(event), + } satisfies RuntimeTimelineEntry; + }); +} diff --git a/src/lib/scripts/api.test.js b/src/lib/scripts/api.test.js new file mode 100644 index 0000000..82d8b5a --- /dev/null +++ b/src/lib/scripts/api.test.js @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { scriptApi, ScriptApiError, scriptErrorMessage } from './api'; + +const originalFetch = globalThis.fetch; + +function mockFetch(handler) { + const calls = []; + globalThis.fetch = async (url, init) => { + const record = { url: String(url), init: init ?? {} }; + calls.push(record); + return handler(record.url, record.init); + }; + return calls; +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function jsonResponse(body, status = 200) { + return new Response(body === null || body === undefined ? '' : JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('scriptApi.readFile', () => { + test('reads a file via /script-api/v1/files?path=', async () => { + const calls = mockFetch(() => jsonResponse({ path: 'demo/a.js', content: 'a', size: 1, mtimeMs: 1, sha256: 'sha256:x' })); + const file = await scriptApi.readFile('demo/a.js'); + expect(file).toMatchObject({ content: 'a', sha256: 'sha256:x' }); + expect(calls[0].url).toBe('/script-api/v1/files?path=demo%2Fa.js'); + expect(calls[0].init.method).toBeUndefined(); + }); + + test('throws ScriptApiError with code on 409', async () => { + mockFetch(() => jsonResponse({ error: { code: 'CONTENT_CONFLICT', message: '磁盘脚本已变化' } }, 409)); + await expect( + scriptApi.writeFile({ path: 'demo/a.js', content: 'a', expectedSha256: 'sha256:old' }), + ).rejects.toMatchObject({ code: 'CONTENT_CONFLICT' }); + }); + + test('throws a ScriptApiError instance preserving status and code', async () => { + mockFetch(() => jsonResponse({ error: { code: 'NOT_FOUND', message: '不存在' } }, 404)); + let caught; + try { + await scriptApi.readFile('demo/missing.js'); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ScriptApiError); + expect(caught.code).toBe('NOT_FOUND'); + expect(caught.status).toBe(404); + expect(scriptErrorMessage(caught)).toBe('脚本文件不存在'); + }); + + test('maps a failed fetch to SERVICE_UNAVAILABLE', async () => { + mockFetch(() => { throw new TypeError('fetch failed'); }); + await expect(scriptApi.readFile('demo/a.js')).rejects.toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + status: 0, + }); + }); +}); + +test('readManifest returns null for an empty body', async () => { + mockFetch(() => new Response('', { status: 200 })); + expect(await scriptApi.readManifest('p1')).toBeNull(); +}); + +test('readManifest returns the manifest object when present', async () => { + mockFetch(() => jsonResponse({ version: 1, projectId: 'p1', projectName: 'demo', updatedAt: '', references: [] })); + const manifest = await scriptApi.readManifest('p1'); + expect(manifest).toMatchObject({ projectId: 'p1', references: [] }); +}); + +test('ensureProject PUTs projectName to the encoded project path', async () => { + const calls = mockFetch(() => jsonResponse({ projectId: 'abc', projectName: 'demo', directory: 'demo' })); + const result = await scriptApi.ensureProject('sha256:abc', 'demo'); + expect(result).toMatchObject({ directory: 'demo' }); + expect(calls[0].url).toBe('/script-api/v1/projects/sha256%3Aabc'); + expect(calls[0].init.method).toBe('PUT'); +}); + +test('deleteProject deletes the encoded project resource', async () => { + const calls = mockFetch(() => jsonResponse({ deleted: true })); + await scriptApi.deleteProject('sha256:abc'); + expect(calls[0].url).toBe('/script-api/v1/projects/sha256%3Aabc'); + expect(calls[0].init.method).toBe('DELETE'); +}); + +test('deleteFolder sends the recursive flag', async () => { + const calls = mockFetch(() => jsonResponse({ deleted: true })); + await scriptApi.deleteFolder('demo', true); + expect(calls[0].url).toBe('/script-api/v1/folders?path=demo&recursive=true'); + expect(calls[0].init.method).toBe('DELETE'); +}); + +test('listTree returns the tree root', async () => { + mockFetch(() => jsonResponse({ kind: 'directory', name: '', path: '', children: [] })); + const tree = await scriptApi.listTree(); + expect(tree).toMatchObject({ kind: 'directory', children: [] }); +}); diff --git a/src/lib/scripts/api.ts b/src/lib/scripts/api.ts new file mode 100644 index 0000000..9681e58 --- /dev/null +++ b/src/lib/scripts/api.ts @@ -0,0 +1,121 @@ +import type { ScriptFile, ScriptManifest, ScriptTreeNode } from './types'; + +const BASE = '/script-api/v1'; + +export const SCRIPT_ERROR_MESSAGES: Record = { + INVALID_PATH: '脚本路径无效', + NOT_FOUND: '脚本文件不存在', + ALREADY_EXISTS: '同名文件或文件夹已存在', + CONTENT_CONFLICT: '磁盘文件已变化,请重新加载或另存', + PAYLOAD_TOO_LARGE: '脚本超过 2 MiB 限制', + UNAUTHORIZED: '脚本服务鉴权失败', + DIRECTORY_NOT_EMPTY: '文件夹不为空', + SERVICE_UNAVAILABLE: '脚本服务不可用', +}; + +export function scriptErrorMessage(error: unknown): string { + if (error instanceof ScriptApiError) { + return SCRIPT_ERROR_MESSAGES[error.code] ?? error.message; + } + if (error instanceof Error) return error.message; + return String(error); +} + +export class ScriptApiError extends Error { + readonly status: number; + readonly code: string; + readonly details: unknown; + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.name = 'ScriptApiError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +async function request(pathname: string, init: RequestInit = {}): Promise { + let response: Response; + try { + response = await fetch(`${BASE}${pathname}`, { + ...init, + headers: { 'content-type': 'application/json', ...((init.headers as Record) ?? {}) }, + }); + } catch (error) { + throw new ScriptApiError(0, 'SERVICE_UNAVAILABLE', '脚本服务不可用', error); + } + if (!response.ok) { + let body: { error?: { code: string; message: string; details?: unknown } } | null = null; + try { + body = (await response.json()) as { error?: { code: string; message: string; details?: unknown } }; + } catch { + body = null; + } + const error = body?.error; + throw new ScriptApiError( + response.status, + error?.code ?? 'UNKNOWN', + error?.message ?? `请求失败 (${response.status})`, + error?.details, + ); + } + return response; +} + +async function parseJson(response: Response): Promise { + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +export const scriptApi = { + async health(): Promise<{ ok: boolean; version: string }> { + const response = await request('/health'); + return parseJson<{ ok: boolean; version: string }>(response); + }, + async listTree(): Promise { + const response = await request('/tree'); + return parseJson(response); + }, + async readFile(path: string): Promise { + const response = await request(`/files?path=${encodeURIComponent(path)}`); + return parseJson(response); + }, + async writeFile(input: { path: string; content: string; expectedSha256: string | null }): Promise { + const response = await request('/files', { method: 'PUT', body: JSON.stringify(input) }); + return parseJson(response); + }, + async deleteFile(path: string, expectedSha256?: string): Promise { + const suffix = expectedSha256 ? `&expectedSha256=${encodeURIComponent(expectedSha256)}` : ''; + await request(`/files?path=${encodeURIComponent(path)}${suffix}`, { method: 'DELETE' }); + }, + async createFolder(path: string): Promise { + await request('/folders', { method: 'POST', body: JSON.stringify({ path }) }); + }, + async deleteFolder(path: string, recursive: boolean): Promise { + await request(`/folders?path=${encodeURIComponent(path)}&recursive=${recursive}`, { method: 'DELETE' }); + }, + async ensureProject(projectId: string, projectName: string): Promise<{ projectId: string; projectName: string; directory: string }> { + const response = await request(`/projects/${encodeURIComponent(projectId)}`, { + method: 'PUT', + body: JSON.stringify({ projectName }), + }); + return parseJson<{ projectId: string; projectName: string; directory: string }>(response); + }, + async deleteProject(projectId: string): Promise { + await request(`/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' }); + }, + async readManifest(projectId: string): Promise { + const response = await request(`/projects/${encodeURIComponent(projectId)}/manifest`); + const text = await response.text(); + return text ? (JSON.parse(text) as ScriptManifest) : null; + }, + async writeManifest(projectId: string, manifest: ScriptManifest): Promise { + await request(`/projects/${encodeURIComponent(projectId)}/manifest`, { + method: 'PUT', + body: JSON.stringify(manifest), + }); + }, + async deleteManifest(projectId: string): Promise { + await request(`/projects/${encodeURIComponent(projectId)}/manifest`, { method: 'DELETE' }); + }, +}; diff --git a/src/lib/scripts/editor-actions.test.js b/src/lib/scripts/editor-actions.test.js new file mode 100644 index 0000000..a045809 --- /dev/null +++ b/src/lib/scripts/editor-actions.test.js @@ -0,0 +1,144 @@ +import { expect, test } from 'bun:test'; +import { + defaultScriptPath, + extractInlineScript, + findScriptAtLine, + initializeInlineScript, + inlineScriptReference, + listScriptRanges, + referenceExistingScript, +} from './editor-actions'; + +const yamlWithRef = `name: demo +agents: + worker: + scheduler: + enabled: true + script: $ref:demo/a.js +`; + +const yamlWithInline = `name: demo +agents: + worker: + scheduler: + script: |- + engine.notify("ok") + console.log("done") +`; + +test('listScriptRanges exposes inline and reference rows for editor decorations', () => { + const yaml = `name: demo +agents: + inline-worker: + scheduler: + script: |- + console.log("inline") + linked-worker: + scheduler: + script: $ref:scripts/linked-worker.js +`; + + expect(listScriptRanges(yaml)).toEqual([ + { + pointer: '/agents/inline-worker/scheduler/script', + agentName: 'inline-worker', + kind: 'inline', + content: 'console.log("inline")', + startLine: 5, + endLine: 6, + }, + { + pointer: '/agents/linked-worker/scheduler/script', + agentName: 'linked-worker', + kind: 'reference', + path: 'scripts/linked-worker.js', + startLine: 9, + endLine: 9, + }, + ]); +}); + +test('defaultScriptPath sanitizes agent names for an editable default', () => { + expect(defaultScriptPath('/agents/report worker@scheduler/scheduler/script')).toBe( + 'scripts/report-worker-scheduler.js', + ); +}); + +test('defaultScriptPath uses projectName as directory prefix when provided', () => { + expect(defaultScriptPath('/agents/worker/scheduler/script', 'my-project')).toBe( + 'my-project/worker.js', + ); +}); + +test('defaultScriptPath preserves readable unicode agent names', () => { + expect(defaultScriptPath('/agents/日报 生成器/scheduler/script')).toBe('scripts/日报-生成器.js'); +}); + +test('findScriptAtLine locates a reference on the script line (1-based)', () => { + expect(findScriptAtLine(yamlWithRef, 6)).toEqual({ + pointer: '/agents/worker/scheduler/script', + kind: 'reference', + path: 'demo/a.js', + }); +}); + +test('findScriptAtLine returns null on a non-script line', () => { + expect(findScriptAtLine(yamlWithRef, 1)).toBeNull(); + expect(findScriptAtLine(yamlWithRef, 4)).toBeNull(); +}); + +test('findScriptAtLine locates inline block content across its line range', () => { + const location = findScriptAtLine(yamlWithInline, 7); + expect(location).toEqual({ + pointer: '/agents/worker/scheduler/script', + kind: 'inline', + content: 'engine.notify("ok")\nconsole.log("done")', + }); + expect(findScriptAtLine(yamlWithInline, 5)).not.toBeNull(); + expect(findScriptAtLine(yamlWithInline, 4)).toBeNull(); +}); + +test('extractInlineScript moves inline code into a file and leaves a $ref', () => { + const result = extractInlineScript(yamlWithInline, '/agents/worker/scheduler/script', 'demo/worker.js'); + expect(result.yamlText).toContain('$ref:demo/worker.js'); + expect(result.yamlText).not.toContain('engine.notify'); + expect(result.content).toContain('engine.notify("ok")'); + expect(result.content).toContain('console.log("done")'); +}); + +test('referenceExistingScript replaces any script value with a $ref', () => { + const result = referenceExistingScript(yamlWithInline, '/agents/worker/scheduler/script', 'shared/alert.js'); + expect(result).toContain('$ref:shared/alert.js'); +}); + +test('initializeInlineScript turns an empty script value into an editable block', () => { + const yaml = `agents: + worker: + scheduler: + script: +`; + const result = initializeInlineScript(yaml, '/agents/worker/scheduler/script'); + expect(result).toContain('script: |-'); + expect(result).not.toContain('__SCRIPT_BLOCK_SENTINEL'); +}); + +test('inlineScriptReference replaces a $ref with a literal block scalar', () => { + const result = inlineScriptReference(yamlWithRef, '/agents/worker/scheduler/script', 'engine.notify("ok")'); + expect(result).toContain('script: |-'); + expect(result).toContain('engine.notify("ok")'); + expect(result).not.toContain('$ref:'); +}); + +test('inlineScriptReference indents multi-line content under the script key', () => { + const result = inlineScriptReference(yamlWithRef, '/agents/worker/scheduler/script', 'line1\nline2'); + expect(result).toContain('script: |-'); + expect(result).toMatch(/ line1/); + expect(result).toMatch(/ line2/); +}); + +test('round-trip: inline then extract yields the original content', () => { + const inlined = inlineScriptReference(yamlWithRef, '/agents/worker/scheduler/script', 'round-trip-code'); + const extracted = extractInlineScript(inlined, '/agents/worker/scheduler/script', 'demo/rt.js'); + expect(extracted.content).toBe('round-trip-code'); + expect(extracted.yamlText).toContain('$ref:demo/rt.js'); +}); diff --git a/src/lib/scripts/editor-actions.ts b/src/lib/scripts/editor-actions.ts new file mode 100644 index 0000000..74b9dd4 --- /dev/null +++ b/src/lib/scripts/editor-actions.ts @@ -0,0 +1,193 @@ +import { dumpYamlObject, parseYamlObject, type YamlMap } from '../yaml'; +import { decodePointer, encodePointer, parseScriptRef, toScriptRef } from './paths'; + +export type ScriptLocation = + | { pointer: string; kind: 'reference'; path: string } + | { pointer: string; kind: 'inline'; content: string }; + +const SENTINEL = '__SCRIPT_BLOCK_SENTINEL_7c3f9a__'; + +function navigate(obj: YamlMap, parts: string[]): unknown { + let current: unknown = obj; + for (const part of parts) { + if (current && typeof current === 'object' && !Array.isArray(current)) { + current = (current as Record)[part]; + } else { + return undefined; + } + } + return current; +} + +function setAt(obj: YamlMap, parts: string[], value: unknown): void { + let current: Record = obj; + for (let i = 0; i < parts.length - 1; i += 1) { + const next = current[parts[i]]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + current[parts[i]] = {}; + current = current[parts[i]] as Record; + } else { + current = next as Record; + } + } + current[parts[parts.length - 1]] = value; +} + +function stripScalarQuotes(value: string): string { + const trimmed = value.trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export type ScriptRange = + | { + pointer: string; + agentName: string; + kind: 'reference'; + path: string; + startLine: number; + endLine: number; + } + | { + pointer: string; + agentName: string; + kind: 'inline'; + content: string; + startLine: number; + endLine: number; + }; + +function scanScriptRanges(lines: string[]): ScriptRange[] { + const ranges: ScriptRange[] = []; + const pathStack: Array<{ indent: number; key: string }> = []; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const match = line.match(/^(\s*)([^\s#:].*?)\s*:(.*)$/); + if (!match) continue; + const indent = match[1].length; + const key = match[2].trim(); + const value = match[3].trim(); + + while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent >= indent) { + pathStack.pop(); + } + pathStack.push({ indent, key }); + const path = pathStack.map((entry) => entry.key); + + if (path.length === 4 && path[0] === 'agents' && path[2] === 'scheduler' && path[3] === 'script') { + const agentName = path[1]; + const pointer = encodePointer(['agents', agentName, 'scheduler', 'script']); + const refPath = parseScriptRef(value); + + if (refPath !== null) { + ranges.push({ pointer, agentName, kind: 'reference', path: refPath, startLine: i + 1, endLine: i + 1 }); + } else if (value === '|' || value === '|-' || value === '|+' || value === '>' || value === '>-' || value === '>+') { + let blockIndent = -1; + const blockLines: string[] = []; + let j = i + 1; + while (j < lines.length) { + const blockLine = lines[j]; + if (blockLine.trim() === '') { + blockLines.push(''); + j += 1; + continue; + } + const leading = blockLine.match(/^(\s*)/)?.[1].length ?? 0; + if (leading <= indent) break; + if (blockIndent < 0) blockIndent = leading; + blockLines.push(blockLine.slice(blockIndent)); + j += 1; + } + const endLine = j - 1; + let content: string; + if (value === '|+' || value === '>+') { + content = blockLines.join('\n'); + } else { + const trimmed = [...blockLines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { + trimmed.pop(); + } + content = trimmed.join('\n'); + if (value === '|' || value === '>') content += '\n'; + } + ranges.push({ pointer, agentName, kind: 'inline', content, startLine: i + 1, endLine: endLine + 1 }); + } else { + ranges.push({ pointer, agentName, kind: 'inline', content: stripScalarQuotes(value), startLine: i + 1, endLine: i + 1 }); + } + } + } + return ranges; +} + +export function listScriptRanges(yamlText: string): ScriptRange[] { + return scanScriptRanges(yamlText.split('\n')); +} + +export function defaultScriptPath(pointer: string, projectName?: string): string { + const parts = decodePointer(pointer); + const rawName = parts[0] === 'agents' && parts[1] ? parts[1] : 'script'; + const safeName = rawName + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}._-]+/gu, '-') + .replace(/^-+|-+$/g, '') || 'script'; + const dir = (projectName?.trim() || 'scripts').toLowerCase().replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^-+|-+$/g, '') || 'scripts'; + return `${dir}/${safeName}.js`; +} + +export function findScriptAtLine(yamlText: string, lineNumber: number): ScriptLocation | null { + const ranges = listScriptRanges(yamlText); + for (const range of ranges) { + if (lineNumber >= range.startLine && lineNumber <= range.endLine) { + if (range.kind === 'reference') { + return { pointer: range.pointer, kind: 'reference', path: range.path }; + } + return { pointer: range.pointer, kind: 'inline', content: range.content }; + } + } + return null; +} + +export function extractInlineScript(yamlText: string, pointer: string, path: string): { yamlText: string; content: string } { + const obj = parseYamlObject(yamlText); + const parts = decodePointer(pointer); + const current = navigate(obj, parts); + const content = typeof current === 'string' ? current : ''; + setAt(obj, parts, toScriptRef(path)); + return { yamlText: dumpYamlObject(obj), content }; +} + +export function referenceExistingScript(yamlText: string, pointer: string, path: string): string { + const obj = parseYamlObject(yamlText); + setAt(obj, decodePointer(pointer), toScriptRef(path)); + return dumpYamlObject(obj); +} + +function emitBlockScalar(indent: string, content: string): string { + const prefix = `${indent} `; + const lines = content.split('\n').map((line) => (line === '' ? '' : prefix + line)); + return `${indent}script: |-\n${lines.join('\n')}`; +} + +export function initializeInlineScript(yamlText: string, pointer: string): string { + return inlineScriptReference(yamlText, pointer, ''); +} + +export function inlineScriptReference(yamlText: string, pointer: string, content: string): string { + const obj = parseYamlObject(yamlText); + setAt(obj, decodePointer(pointer), SENTINEL); + const dumped = dumpYamlObject(obj); + const lines = dumped.split('\n'); + const pattern = new RegExp(`^(\\s*)script: ${SENTINEL}\\s*$`); + for (let i = 0; i < lines.length; i += 1) { + const match = lines[i].match(pattern); + if (match) { + lines[i] = emitBlockScalar(match[1], content); + break; + } + } + return lines.join('\n'); +} diff --git a/src/lib/scripts/env-interpolation.test.js b/src/lib/scripts/env-interpolation.test.js new file mode 100644 index 0000000..29d9eb4 --- /dev/null +++ b/src/lib/scripts/env-interpolation.test.js @@ -0,0 +1,91 @@ +import { expect, test } from 'bun:test'; +import yaml from 'js-yaml'; +import { interpolateGlobalEnv } from './env-interpolation'; + +test('expands non-secret ${VAR} from global env', () => { + const yaml = 'model: ${LLM_MODEL}\nbase: ${LLM_API_ENDPOINT}'; + const env = [ + { name: 'LLM_MODEL', value: 'GLM-5.2', secret: false }, + { name: 'LLM_API_ENDPOINT', value: 'https://ark.example/api', secret: false }, + ]; + expect(interpolateGlobalEnv(yaml, env)).toBe('model: GLM-5.2\nbase: https://ark.example/api'); +}); + +test('leaves secret ${VAR} intact (panel redacts to ********)', () => { + const yaml = 'key: ${LLM_API_KEY}'; + const env = [{ name: 'LLM_API_KEY', value: '********', secret: true }]; + expect(interpolateGlobalEnv(yaml, env)).toBe('key: ${LLM_API_KEY}'); +}); + +test('leaves unknown ${VAR} intact for backend resolution', () => { + expect(interpolateGlobalEnv('x: ${UNKNOWN}', [ + { name: 'OTHER', value: 'v', secret: false }, + ])).toBe('x: ${UNKNOWN}'); +}); + +test('case-insensitive name match (mirrors backend EqualFold)', () => { + const yaml = 'm: ${llm_model}'; + const env = [{ name: 'LLM_MODEL', value: 'GLM-5.2', secret: false }]; + expect(interpolateGlobalEnv(yaml, env)).toBe('m: GLM-5.2'); +}); + +test('no-op when env is empty or missing', () => { + expect(interpolateGlobalEnv('x: ${A}', [])).toBe('x: ${A}'); + expect(interpolateGlobalEnv('x: ${A}', undefined)).toBe('x: ${A}'); + expect(interpolateGlobalEnv('x: ${A}', null)).toBe('x: ${A}'); +}); + +test('expands multiple references in one value', () => { + const yaml = 'url: ${ENDPOINT}/${PATH}'; + const env = [ + { name: 'ENDPOINT', value: 'https://x', secret: false }, + { name: 'PATH', value: 'v1', secret: false }, + ]; + expect(interpolateGlobalEnv(yaml, env)).toBe('url: https://x/v1'); +}); + +test('skips redacted sentinel value even on a non-secret entry', () => { + const yaml = 'x: ${WEIRD}'; + const env = [{ name: 'WEIRD', value: '********', secret: false }]; + expect(interpolateGlobalEnv(yaml, env)).toBe('x: ${WEIRD}'); +}); + +test('preserves YAML map value form {value: ${VAR}}', () => { + const yaml = 'ANTHROPIC_BASE_URL:\n value: ${LLM_API_ENDPOINT}'; + const env = [{ name: 'LLM_API_ENDPOINT', value: 'https://ark.example/api', secret: false }]; + expect(interpolateGlobalEnv(yaml, env)).toBe('ANTHROPIC_BASE_URL:\n value: https://ark.example/api'); +}); + +test('preserves every interpolated global env value as a YAML string', () => { + const source = [ + 'env:', + ' JSON_ARRAY:', + ' value: ${JSON_ARRAY}', + ' JSON_OBJECT:', + ' value: ${JSON_OBJECT}', + ' NUMBER:', + ' value: ${NUMBER}', + ' BOOLEAN:', + ' value: ${BOOLEAN}', + ' NULL_LIKE:', + ' value: ${NULL_LIKE}', + ' ORDINARY:', + ' value: ${ORDINARY}', + ].join('\n'); + const values = { + JSON_ARRAY: '[{"webhook":"https://example.test"}]', + JSON_OBJECT: '{"repository":"owner/repo"}', + NUMBER: '30000', + BOOLEAN: 'true', + NULL_LIKE: 'null', + ORDINARY: 'claude-sonnet', + }; + const env = Object.entries(values).map(([name, value]) => ({ name, value, secret: false })); + + const parsed = yaml.load(interpolateGlobalEnv(source, env)); + + for (const [name, value] of Object.entries(values)) { + expect(parsed.env[name].value).toBe(value); + expect(typeof parsed.env[name].value).toBe('string'); + } +}); diff --git a/src/lib/scripts/env-interpolation.ts b/src/lib/scripts/env-interpolation.ts new file mode 100644 index 0000000..29cf9ef --- /dev/null +++ b/src/lib/scripts/env-interpolation.ts @@ -0,0 +1,71 @@ +import type { EnvVarSpec } from '../../gen/agentcompose/v2/agentcompose_pb'; +import { dumpYamlObject, parseYamlObject } from '../yaml'; + +// Mirrors the backend `envReferencePattern` in pkg/compose/normalize.go so the +// references we interpolate are exactly the ones `compose.Normalize` would +// otherwise expand from the daemon process env (`.env`). Keeping the pattern in +// sync is what lets the web console override the daemon's `.env`-based +// expansion with the global-env panel values at apply time. +const ENV_REFERENCE_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +// GetGlobalEnv redacts secrets to this sentinel (see +// pkg/agentcompose/api/settings_v2.go `secretRedactedValue`). Expanding a +// secret reference to it would corrupt the value, so secret references are +// left intact for the backend to resolve. +const SECRET_REDACTED = '********'; + +export type InterpolatableEnvVar = Pick; + +function interpolateString(value: string, lookup: ReadonlyMap, state: { matched: boolean }): string { + return value.replace(ENV_REFERENCE_PATTERN, (match, name: string) => { + const replacement = lookup.get(name.toUpperCase()); + if (replacement !== undefined) state.matched = true; + return replacement === undefined ? match : replacement; + }); +} + +function interpolateValues(value: unknown, lookup: ReadonlyMap, state: { matched: boolean }): unknown { + if (typeof value === 'string') return interpolateString(value, lookup, state); + if (Array.isArray(value)) return value.map(item => interpolateValues(item, lookup, state)); + if (value && typeof value === 'object') { + for (const [key, item] of Object.entries(value as Record)) { + (value as Record)[key] = interpolateValues(item, lookup, state); + } + } + return value; +} + +/** + * Expand `${VAR}` references in YAML text using global-env panel values. + * + * Only non-secret variables are expanded. Secrets are redacted by the backend + * (`********`), so they cannot be expanded client-side; their references are + * left intact; the runtime LLM facade overrides API-key env with a facade + * token (see pkg/llms/runtimefacade/config.go `ensureSessionClaudeConfig`), + * so a leftover `${LLM_API_KEY}` is resolved by the backend and harmless. + * + * Unknown references (no matching panel variable) are also left intact so the + * backend can resolve them from the daemon process env, preserving the + * existing behavior for variables not managed by the panel. + * + * Name matching is case-insensitive to mirror the backend + * `LookupGlobalEnv` (strings.EqualFold). + */ +export function interpolateGlobalEnv(yamlText: string, env: ReadonlyArray | undefined | null): string { + if (!env || env.length === 0) return yamlText; + const lookup = new Map(); + for (const item of env) { + const name = (item.name ?? '').trim(); + if (!name || item.secret) continue; + const value = item.value ?? ''; + if (value === '' || value === SECRET_REDACTED) continue; + lookup.set(name.toUpperCase(), value); + } + if (lookup.size === 0) return yamlText; + const yamlObject = parseYamlObject(yamlText); + const state = { matched: false }; + interpolateValues(yamlObject, lookup, state); + if (!state.matched) return yamlText; + const interpolated = dumpYamlObject(yamlObject); + return yamlText.endsWith('\n') ? interpolated : interpolated.replace(/\n$/, ''); +} diff --git a/src/lib/scripts/global-env.ts b/src/lib/scripts/global-env.ts new file mode 100644 index 0000000..e6975e5 --- /dev/null +++ b/src/lib/scripts/global-env.ts @@ -0,0 +1,39 @@ +import { GetGlobalEnvRequest, type EnvVarSpec } from '../../gen/agentcompose/v2/agentcompose_pb'; + +// In-memory cache of the global-env panel values. The backend redacts secrets +// (********) on read, so this holds no plaintext secrets. +// +// Preview re-runs interpolation on every editor change, so we cache to avoid +// hammering GetGlobalEnv; the cache is invalidated whenever the panel is saved +// (see GlobalEnvPanel.svelte -> invalidateGlobalEnvCache). +let cache: EnvVarSpec[] | undefined; +let inflight: Promise | undefined; + +// rpc.ts touches `window` at module top level (createConnectTransport reads +// window.location.origin). Importing it eagerly here would pull that side +// effect into any module that imports global-env (e.g. Toolbar -> global-env), +// breaking non-browser test environments. Load it lazily on first use instead. +async function loadSettingsService() { + const mod = await import('../rpc'); + return mod.settingsService; +} + +/** Returns the global-env panel values, caching the result until invalidated. */ +export async function getGlobalEnvForInterpolation(): Promise { + if (cache) return cache; + if (inflight) return inflight; + inflight = (async () => { + const settingsService = await loadSettingsService(); + const response = await settingsService.getGlobalEnv(new GetGlobalEnvRequest()); + cache = response.env ?? []; + return cache; + })().finally(() => { + inflight = undefined; + }); + return inflight; +} + +/** Drop the cache so the next read refetches from the backend. */ +export function invalidateGlobalEnvCache(): void { + cache = undefined; +} diff --git a/src/lib/scripts/paths.test.js b/src/lib/scripts/paths.test.js new file mode 100644 index 0000000..4094d8d --- /dev/null +++ b/src/lib/scripts/paths.test.js @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import { decodePointer, encodePointer, parseScriptRef, toScriptRef } from './paths'; + +describe('script refs', () => { + test('parses and formats explicit project paths', () => { + expect(parseScriptRef('$ref:data-pipeline/scripts/a.js')).toBe('data-pipeline/scripts/a.js'); + expect(toScriptRef('shared-tools/a.js')).toBe('$ref:shared-tools/a.js'); + }); + + test.each(['$ref:../a.js', '$ref:/a.js', '$ref:a.txt', 'prefix:$ref:a.js', '$ref:demo/.metadata/x.js', '$ref:demo\\a.js'])( + 'rejects %s', + (value) => { + expect(parseScriptRef(value)).toBeNull(); + }, + ); + + test('rejects non-string values', () => { + expect(parseScriptRef(null)).toBeNull(); + expect(parseScriptRef(undefined)).toBeNull(); + expect(parseScriptRef(123)).toBeNull(); + }); + + test('escapes agent names in JSON pointers', () => { + const pointer = encodePointer(['agents', 'a/b~c', 'scheduler', 'script']); + expect(pointer).toBe('/agents/a~1b~0c/scheduler/script'); + expect(decodePointer(pointer)).toEqual(['agents', 'a/b~c', 'scheduler', 'script']); + }); +}); diff --git a/src/lib/scripts/paths.ts b/src/lib/scripts/paths.ts new file mode 100644 index 0000000..0f52bb1 --- /dev/null +++ b/src/lib/scripts/paths.ts @@ -0,0 +1,15 @@ +const SAFE_SCRIPT_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*(?:^|\/)\.metadata(?:\/|$))[A-Za-z0-9._ -]+(?:\/[A-Za-z0-9._ -]+)*\.js$/; + +export function parseScriptRef(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('$ref:')) return null; + const path = value.slice(5); + return SAFE_SCRIPT_PATH.test(path) && !path.includes('\\') ? path : null; +} + +export const toScriptRef = (path: string): string => `$ref:${path}`; + +export const encodePointer = (parts: string[]): string => + `/${parts.map((part) => part.replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}`; + +export const decodePointer = (pointer: string): string[] => + pointer.slice(1).split('/').map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~')); diff --git a/src/lib/scripts/project-lifecycle.test.js b/src/lib/scripts/project-lifecycle.test.js new file mode 100644 index 0000000..eda6e63 --- /dev/null +++ b/src/lib/scripts/project-lifecycle.test.js @@ -0,0 +1,103 @@ +import { expect, test } from 'bun:test'; +import { hashBrowserContent, restoreProjectScripts } from './project-lifecycle'; + +const daemonYaml = 'name: demo\nagents:\n worker:\n scheduler:\n script: code\n'; + +test('returns ref YAML when manifest and contents match', async () => { + const result = await restoreProjectScripts({ + projectId: 'p1', + daemonYaml, + api: { + readManifest: async () => ({ + version: 1, + projectId: 'p1', + projectName: 'demo', + updatedAt: '', + references: [{ pointer: '/agents/worker/scheduler/script', path: 'demo/a.js', contentSha256: 'sha256:x' }], + }), + readFile: async () => ({ content: 'code', sha256: 'sha256:x' }), + }, + hashContent: async () => 'sha256:x', + }); + expect(result.yamlText).toContain('$ref:demo/a.js'); + expect(result.warnings).toEqual([]); +}); + +test('returns unchanged daemon YAML plus warning when the referenced file is absent', async () => { + const result = await restoreProjectScripts({ + projectId: 'p1', + daemonYaml, + api: { + readManifest: async () => ({ + version: 1, + projectId: 'p1', + projectName: 'demo', + updatedAt: '', + references: [{ pointer: '/agents/worker/scheduler/script', path: 'demo/missing.js', contentSha256: 'sha256:code' }], + }), + readFile: async () => { + throw new Error('NOT_FOUND'); + }, + }, + hashContent: async () => 'sha256:code', + }); + expect(result.yamlText).toContain('script: code'); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].path).toBe('demo/missing.js'); +}); + +test('keeps daemon inline code when daemon hash no longer matches manifest', async () => { + const result = await restoreProjectScripts({ + projectId: 'p1', + daemonYaml, + api: { + readManifest: async () => ({ + version: 1, + projectId: 'p1', + projectName: 'demo', + updatedAt: '', + references: [{ pointer: '/agents/worker/scheduler/script', path: 'demo/a.js', contentSha256: 'sha256:original' }], + }), + readFile: async () => ({ content: 'code', sha256: 'sha256:original' }), + }, + hashContent: async () => 'sha256:changed', + }); + expect(result.yamlText).toContain('script: code'); + expect(result.warnings[0].path).toBe('demo/a.js'); +}); + +test('returns daemon YAML unchanged when manifest is null', async () => { + const result = await restoreProjectScripts({ + projectId: 'p1', + daemonYaml, + api: { + readManifest: async () => null, + readFile: async () => { throw new Error('should not read'); }, + }, + hashContent: async () => 'sha256:x', + }); + expect(result.yamlText).toContain('script: code'); + expect(result.references).toEqual([]); + expect(result.warnings).toEqual([]); +}); + +test('hashBrowserContent produces the sha256: hex format matching the backend', async () => { + const hash = await hashBrowserContent('code'); + expect(hash).toMatch(/^sha256:[a-f0-9]{64}$/); + const expected = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('code')); + const hex = Array.from(new Uint8Array(expected)).map((b) => b.toString(16).padStart(2, '0')).join(''); + expect(hash).toBe(`sha256:${hex}`); +}); + +test('hashBrowserContent works when Web Crypto subtle is unavailable', async () => { + const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: {} }); + try { + expect(await hashBrowserContent('code')).toBe( + 'sha256:5694d08a2e53ffcae0c3103e5ad6f6076abd960eb1f8a56577040bc1028f702b', + ); + } finally { + if (cryptoDescriptor) Object.defineProperty(globalThis, 'crypto', cryptoDescriptor); + else delete globalThis.crypto; + } +}); diff --git a/src/lib/scripts/project-lifecycle.ts b/src/lib/scripts/project-lifecycle.ts new file mode 100644 index 0000000..11aaec3 --- /dev/null +++ b/src/lib/scripts/project-lifecycle.ts @@ -0,0 +1,30 @@ +import { scriptApi } from './api'; +import { restoreScriptReferences } from './references'; +import { sha256 } from '../sha256'; + +export function canonicalProjectId(projectId: string): string { + const value = String(projectId ?? ''); + return value.startsWith('sha256:') ? value.slice(7) : value; +} + +export async function hashBrowserContent(content: string): Promise { + const data = new TextEncoder().encode(content); + const digest = await sha256(data); + const hex = Array.from(digest) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + return `sha256:${hex}`; +} + +export async function restoreProjectScripts(options: { + projectId: string; + daemonYaml: string; + api: Pick; + hashContent?: (content: string) => Promise; +}) { + const manifest = await options.api.readManifest(options.projectId); + return restoreScriptReferences(options.daemonYaml, manifest, { + readFile: options.api.readFile, + hashContent: options.hashContent ?? hashBrowserContent, + }); +} diff --git a/src/lib/scripts/references.test.js b/src/lib/scripts/references.test.js new file mode 100644 index 0000000..56c6334 --- /dev/null +++ b/src/lib/scripts/references.test.js @@ -0,0 +1,73 @@ +import { expect, test } from 'bun:test'; +import { expandScriptReferences, restoreScriptReferences, replaceInlineWithRef, replaceRefWithInline } from './references'; + +const yamlText = `name: demo\nagents:\n worker:\n scheduler:\n enabled: true\n script: $ref:demo/scripts/job.js\n`; + +test('expands only a cloned request YAML and returns manifest references', async () => { + const result = await expandScriptReferences(yamlText, async (path) => ({ path, content: 'engine.notify("ok");', sha256: 'sha256:ok' })); + expect(yamlText).toContain('$ref:demo/scripts/job.js'); + expect(result.yamlText).toContain('engine.notify'); + expect(result.yamlText).not.toContain('$ref:'); + expect(result.references).toEqual([{ pointer: '/agents/worker/scheduler/script', path: 'demo/scripts/job.js', contentSha256: 'sha256:ok' }]); +}); + +test('expand does not mutate refs that are not $ref values', async () => { + const inlineYaml = `name: demo\nagents:\n worker:\n scheduler:\n script: console.log("inline")\n`; + const result = await expandScriptReferences(inlineYaml, async () => ({ path: '', content: '', sha256: '' })); + expect(result.references).toEqual([]); + expect(result.yamlText).toContain('console.log'); +}); + +test('restores a ref only when disk and daemon content hashes both match', async () => { + const inline = `name: demo\nagents:\n worker:\n scheduler:\n script: |\n engine.notify("ok");\n`; + const manifest = { version: 1, projectId: 'p', projectName: 'demo', updatedAt: '', references: [{ pointer: '/agents/worker/scheduler/script', path: 'demo/scripts/job.js', contentSha256: 'sha256:ok' }] }; + const restored = await restoreScriptReferences(inline, manifest, { + readFile: async () => ({ content: 'engine.notify("ok");\n', sha256: 'sha256:ok' }), + hashContent: async () => 'sha256:ok', + }); + expect(restored.yamlText).toContain('$ref:demo/scripts/job.js'); + expect(restored.warnings).toEqual([]); +}); + +test('keeps daemon inline code when the referenced file is missing', async () => { + const inline = `name: demo\nagents:\n worker:\n scheduler:\n script: old-code\n`; + const manifest = { version: 1, projectId: 'p', projectName: 'demo', updatedAt: '', references: [{ pointer: '/agents/worker/scheduler/script', path: 'demo/missing.js', contentSha256: 'sha256:old' }] }; + const restored = await restoreScriptReferences(inline, manifest, { readFile: async () => { throw new Error('missing'); }, hashContent: async () => 'sha256:old' }); + expect(restored.yamlText).toContain('script: old-code'); + expect(restored.warnings[0].path).toBe('demo/missing.js'); +}); + +test('keeps daemon inline code when daemon hash no longer matches manifest', async () => { + const inline = `name: demo\nagents:\n worker:\n scheduler:\n script: changed-code\n`; + const manifest = { version: 1, projectId: 'p', projectName: 'demo', updatedAt: '', references: [{ pointer: '/agents/worker/scheduler/script', path: 'demo/scripts/job.js', contentSha256: 'sha256:original' }] }; + const restored = await restoreScriptReferences(inline, manifest, { + readFile: async () => ({ content: 'changed-code', sha256: 'sha256:original' }), + hashContent: async () => 'sha256:changed', + }); + expect(restored.yamlText).toContain('script: changed-code'); + expect(restored.warnings[0].path).toBe('demo/scripts/job.js'); +}); + +test('restore with null manifest returns the daemon YAML unchanged', async () => { + const inline = `name: demo\nagents:\n worker:\n scheduler:\n script: code\n`; + const restored = await restoreScriptReferences(inline, null, { + readFile: async () => ({ content: '', sha256: '' }), + hashContent: async () => '', + }); + expect(restored.references).toEqual([]); + expect(restored.warnings).toEqual([]); + expect(restored.yamlText).toContain('script: code'); +}); + +test('replaceInlineWithRef swaps an inline script value for a $ref', () => { + const inline = `name: demo\nagents:\n worker:\n scheduler:\n script: real-code\n`; + const result = replaceInlineWithRef(inline, '/agents/worker/scheduler/script', 'demo/worker.js'); + expect(result).toContain('$ref:demo/worker.js'); + expect(result).not.toContain('real-code'); +}); + +test('replaceRefWithInline swaps a $ref value for inline content', () => { + const result = replaceRefWithInline(yamlText, '/agents/worker/scheduler/script', 'engine.notify("ok")'); + expect(result).toContain('engine.notify("ok")'); + expect(result).not.toContain('$ref:'); +}); diff --git a/src/lib/scripts/references.ts b/src/lib/scripts/references.ts new file mode 100644 index 0000000..b5d52a1 --- /dev/null +++ b/src/lib/scripts/references.ts @@ -0,0 +1,150 @@ +import type { ScriptFile, ScriptManifest, ScriptReference } from './types'; +import { dumpYamlObject, parseYamlObject, type YamlMap } from '../yaml'; +import { decodePointer, encodePointer, parseScriptRef, toScriptRef } from './paths'; + +type ReadFile = (path: string) => Promise>; +type HashContent = (content: string) => Promise; + +interface ScriptLocation { + agentName: string; + pointer: string; + path: string; +} + +function navigate(obj: YamlMap, parts: string[]): unknown { + let current: unknown = obj; + for (const part of parts) { + if (current && typeof current === 'object' && !Array.isArray(current)) { + current = (current as Record)[part]; + } else { + return undefined; + } + } + return current; +} + +function setAt(obj: YamlMap, parts: string[], value: unknown): void { + let current: Record = obj; + for (let i = 0; i < parts.length - 1; i += 1) { + const next = current[parts[i]]; + if (!next || typeof next !== 'object' || Array.isArray(next)) { + current[parts[i]] = {}; + current = current[parts[i]] as Record; + } else { + current = next as Record; + } + } + current[parts[parts.length - 1]] = value; +} + +function collectScriptLocations(obj: YamlMap): ScriptLocation[] { + const locations: ScriptLocation[] = []; + const agents = obj.agents; + if (!agents || typeof agents !== 'object' || Array.isArray(agents)) return locations; + for (const [agentName, agentDef] of Object.entries(agents as Record)) { + if (!agentDef || typeof agentDef !== 'object' || Array.isArray(agentDef)) continue; + const scheduler = (agentDef as Record).scheduler; + if (!scheduler || typeof scheduler !== 'object' || Array.isArray(scheduler)) continue; + const scriptValue = (scheduler as Record).script; + const refPath = parseScriptRef(scriptValue); + if (refPath === null) continue; + locations.push({ + agentName, + pointer: encodePointer(['agents', agentName, 'scheduler', 'script']), + path: refPath, + }); + } + return locations; +} + +export async function expandScriptReferences( + yamlText: string, + readFile: ReadFile, +): Promise<{ yamlText: string; references: ScriptReference[] }> { + const obj = parseYamlObject(yamlText); + const locations = collectScriptLocations(obj); + + const files = new Map>(); + for (const location of locations) { + if (!files.has(location.path)) { + files.set(location.path, await readFile(location.path)); + } + } + + const references: ScriptReference[] = []; + for (const location of locations) { + const file = files.get(location.path)!; + setAt(obj, ['agents', location.agentName, 'scheduler', 'script'], file.content); + references.push({ pointer: location.pointer, path: location.path, contentSha256: file.sha256 }); + } + + return { yamlText: dumpYamlObject(obj), references }; +} + +export async function restoreScriptReferences( + daemonYaml: string, + manifest: ScriptManifest | null, + deps: { readFile: ReadFile; hashContent: HashContent }, +): Promise<{ + yamlText: string; + references: ScriptReference[]; + warnings: Array<{ pointer: string; path: string; reason: string }>; +}> { + const obj = parseYamlObject(daemonYaml); + const references: ScriptReference[] = []; + const warnings: Array<{ pointer: string; path: string; reason: string }> = []; + + if (!manifest || !Array.isArray(manifest.references)) { + return { yamlText: dumpYamlObject(obj), references, warnings }; + } + + for (const ref of manifest.references) { + const parts = decodePointer(ref.pointer); + const inlineValue = navigate(obj, parts); + + if (typeof inlineValue !== 'string') { + warnings.push({ pointer: ref.pointer, path: ref.path, reason: 'script 字段缺失或不是字符串' }); + continue; + } + + const daemonHash = await deps.hashContent(inlineValue); + if (daemonHash !== ref.contentSha256) { + warnings.push({ pointer: ref.pointer, path: ref.path, reason: 'daemon 内联内容已变化,保留当前代码' }); + continue; + } + + let diskFile: Pick | null = null; + try { + diskFile = await deps.readFile(ref.path); + } catch { + diskFile = null; + } + + if (!diskFile) { + warnings.push({ pointer: ref.pointer, path: ref.path, reason: '引用文件缺失,已回退为内联代码' }); + continue; + } + + if (diskFile.sha256 !== ref.contentSha256) { + warnings.push({ pointer: ref.pointer, path: ref.path, reason: '磁盘脚本已变化,保留当前内联代码' }); + continue; + } + + setAt(obj, parts, toScriptRef(ref.path)); + references.push(ref); + } + + return { yamlText: dumpYamlObject(obj), references, warnings }; +} + +export function replaceInlineWithRef(yamlText: string, pointer: string, path: string): string { + const obj = parseYamlObject(yamlText); + setAt(obj, decodePointer(pointer), toScriptRef(path)); + return dumpYamlObject(obj); +} + +export function replaceRefWithInline(yamlText: string, pointer: string, content: string): string { + const obj = parseYamlObject(yamlText); + setAt(obj, decodePointer(pointer), content); + return dumpYamlObject(obj); +} diff --git a/src/lib/scripts/request-pipeline.test.js b/src/lib/scripts/request-pipeline.test.js new file mode 100644 index 0000000..628db66 --- /dev/null +++ b/src/lib/scripts/request-pipeline.test.js @@ -0,0 +1,155 @@ +import { expect, test } from 'bun:test'; +import { yamlToSpec } from '../yaml'; +import { prepareScriptRequest } from './request-pipeline'; + +const refYaml = 'name: demo\nagents:\n worker:\n scheduler:\n script: $ref:demo/a.js\n'; + +test('validate uses in-memory dirty content without saving it', async () => { + let flushCalls = 0; + const result = await prepareScriptRequest({ + mode: 'validate', + editorYaml: refYaml, + workspace: { + getContent: () => 'dirty code', + flushDirty: async () => { flushCalls += 1; }, + }, + readFile: async () => ({ content: 'disk code', sha256: 'sha256:disk' }), + hashContent: async () => 'sha256:dirty', + }); + expect(flushCalls).toBe(0); + expect(result.yamlText).toContain('dirty code'); + expect(result.yamlText).not.toContain('$ref:'); + expect(result.references).toEqual([ + { pointer: '/agents/worker/scheduler/script', path: 'demo/a.js', contentSha256: 'sha256:dirty' }, + ]); +}); + +test('save flushes dirty scripts before expanding', async () => { + const order = []; + await prepareScriptRequest({ + mode: 'save', + editorYaml: refYaml, + workspace: { + getContent: () => undefined, + flushDirty: async () => { order.push('flush'); }, + }, + readFile: async () => { order.push('read'); return { content: 'saved code', sha256: 'sha256:saved' }; }, + }); + expect(order).toEqual(['flush', 'read']); +}); + +test('run flushes dirty scripts before expanding', async () => { + const order = []; + await prepareScriptRequest({ + mode: 'run', + editorYaml: refYaml, + workspace: { + getContent: () => undefined, + flushDirty: async () => { order.push('flush'); }, + }, + readFile: async () => { order.push('read'); return { content: 'run code', sha256: 'sha256:run' }; }, + }); + expect(order).toEqual(['flush', 'read']); +}); + +test('validate falls back to disk when no in-memory content exists', async () => { + const reads = []; + const result = await prepareScriptRequest({ + mode: 'validate', + editorYaml: refYaml, + workspace: { + getContent: () => undefined, + flushDirty: async () => {}, + }, + readFile: async (path) => { reads.push(path); return { content: 'disk code', sha256: 'sha256:disk' }; }, + }); + expect(reads).toEqual(['demo/a.js']); + expect(result.yamlText).toContain('disk code'); +}); + +test('inline-only YAML does not access the script service', async () => { + const inlineYaml = 'name: demo\nagents:\n worker:\n scheduler:\n script: inline-code\n'; + let reads = 0; + const result = await prepareScriptRequest({ + mode: 'validate', + editorYaml: inlineYaml, + workspace: { + getContent: () => undefined, + flushDirty: async () => {}, + }, + readFile: async () => { + reads += 1; + throw new Error('script service unavailable'); + }, + }); + expect(reads).toBe(0); + expect(result.yamlText).toContain('script: inline-code'); + expect(result.references).toEqual([]); +}); + +test('a referenced script blocks the request when the service is unavailable', async () => { + await expect(prepareScriptRequest({ + mode: 'validate', + editorYaml: refYaml, + workspace: { + getContent: () => undefined, + flushDirty: async () => {}, + }, + readFile: async () => { + const error = new Error('脚本服务不可用'); + error.code = 'SERVICE_UNAVAILABLE'; + throw error; + }, + })).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' }); +}); + +test('expands ${VAR} from globalEnv before returning yamlText', async () => { + const yaml = 'name: demo\nagents:\n worker:\n env:\n ANTHROPIC_MODEL:\n value: ${LLM_MODEL}\n'; + const result = await prepareScriptRequest({ + mode: 'validate', + editorYaml: yaml, + workspace: { getContent: () => undefined, flushDirty: async () => {} }, + readFile: async () => ({ content: '', sha256: '' }), + globalEnv: [{ name: 'LLM_MODEL', value: 'GLM-5.2', secret: false }], + }); + expect(result.yamlText).toContain('value: GLM-5.2'); + expect(result.yamlText).not.toContain('${LLM_MODEL}'); +}); + +test('leaves secret ${VAR} intact when expanding globalEnv', async () => { + const yaml = 'env:\n ANTHROPIC_API_KEY:\n value: ${LLM_API_KEY}\n secret: true\n'; + const result = await prepareScriptRequest({ + mode: 'validate', + editorYaml: yaml, + workspace: { getContent: () => undefined, flushDirty: async () => {} }, + readFile: async () => ({ content: '', sha256: '' }), + globalEnv: [{ name: 'LLM_API_KEY', value: '********', secret: true }], + }); + expect(result.yamlText).toContain('value: ${LLM_API_KEY}'); +}); + +test('global env values remain strings through request preparation and protobuf decoding', async () => { + const values = { + JSON_ARRAY: '[{"webhook":"https://example.test"}]', + JSON_OBJECT: '{"repository":"owner/repo"}', + NUMBER: '30000', + BOOLEAN: 'true', + NULL_LIKE: 'null', + }; + const entries = Object.keys(values) + .map((name) => ` ${name}:\n value: \${${name}}`) + .join('\n'); + const result = await prepareScriptRequest({ + mode: 'validate', + editorYaml: `name: demo\nagents:\n worker:\n env:\n${entries}\n`, + workspace: { getContent: () => undefined, flushDirty: async () => {} }, + readFile: async () => ({ content: '', sha256: '' }), + globalEnv: Object.entries(values).map(([name, value]) => ({ name, value, secret: false })), + }); + + const decoded = yamlToSpec(result.yamlText); + + expect(decoded.error).toBeUndefined(); + expect(Object.fromEntries(decoded.spec.agents[0].env.map(({ name, value }) => [name, value]))) + .toEqual(values); +}); diff --git a/src/lib/scripts/request-pipeline.ts b/src/lib/scripts/request-pipeline.ts new file mode 100644 index 0000000..a4e9c20 --- /dev/null +++ b/src/lib/scripts/request-pipeline.ts @@ -0,0 +1,43 @@ +import type { ScriptFile } from './types'; +import { expandScriptReferences } from './references'; +import { hashBrowserContent } from './project-lifecycle'; +import { interpolateGlobalEnv, type InterpolatableEnvVar } from './env-interpolation'; + +export interface ScriptRequestWorkspace { + getContent(path: string): string | undefined; + flushDirty(): Promise; +} + +export async function prepareScriptRequest(options: { + mode: 'validate' | 'save' | 'run'; + editorYaml: string; + workspace: ScriptRequestWorkspace; + readFile: (path: string) => Promise>; + hashContent?: (content: string) => Promise; + // Global-env panel values used to expand `${VAR}` references in the YAML + // before it reaches the backend. The backend's `compose.Normalize` would + // otherwise expand them from the daemon process env (`.env`), which is not + // what the user edits in the panel; expanding here makes panel edits take + // effect on the next apply. Secrets are redacted by the backend and are left + // intact (see interpolateGlobalEnv). + globalEnv?: ReadonlyArray; +}) { + if (options.mode !== 'validate') { + await options.workspace.flushDirty(); + } + const expanded = await expandScriptReferences(options.editorYaml, async (path) => { + const inMemory = options.workspace.getContent(path); + if (options.mode === 'validate' && inMemory !== undefined) { + return { + path, + content: inMemory, + sha256: await (options.hashContent ?? hashBrowserContent)(inMemory), + }; + } + return options.readFile(path); + }); + const yamlText = options.globalEnv && options.globalEnv.length > 0 + ? interpolateGlobalEnv(expanded.yamlText, options.globalEnv) + : expanded.yamlText; + return { yamlText, references: expanded.references }; +} diff --git a/src/lib/scripts/tree.test.js b/src/lib/scripts/tree.test.js new file mode 100644 index 0000000..0bcfd40 --- /dev/null +++ b/src/lib/scripts/tree.test.js @@ -0,0 +1,65 @@ +import { expect, test } from 'bun:test'; +import { collectScriptDirectories, collectScriptFiles, countScriptFiles, filterScriptDirectories } from './tree'; + +const tree = { + kind: 'directory', + name: '', + path: '', + children: [ + { + kind: 'directory', + name: 'scripts', + path: 'scripts', + children: [ + { kind: 'file', name: 'one.js', path: 'scripts/one.js', size: 1, mtimeMs: 1, sha256: 'a' }, + { + kind: 'directory', + name: 'nested', + path: 'scripts/nested', + children: [ + { kind: 'file', name: 'two.js', path: 'scripts/nested/two.js', size: 2, mtimeMs: 2, sha256: 'b' }, + ], + }, + ], + }, + ], +}; + +test('collectScriptFiles flattens files in tree order', () => { + expect(collectScriptFiles(tree).map((file) => file.path)).toEqual([ + 'scripts/one.js', + 'scripts/nested/two.js', + ]); +}); + +test('countScriptFiles handles a missing tree', () => { + expect(countScriptFiles(null)).toBe(0); + expect(countScriptFiles(tree)).toBe(2); +}); + +test('collectScriptDirectories returns nested directory paths', () => { + expect(collectScriptDirectories(tree)).toEqual(['scripts', 'scripts/nested']); +}); + +test('directory search keeps a matched directory with its complete subtree', () => { + const result = filterScriptDirectories(tree, 'SCRIPTS'); + expect(collectScriptFiles(result.tree).map((file) => file.path)).toEqual([ + 'scripts/one.js', + 'scripts/nested/two.js', + ]); + expect(result.expandedPaths).toEqual(new Set(['scripts', 'scripts/nested'])); +}); + +test('directory search keeps parent context for a nested match', () => { + const result = filterScriptDirectories(tree, 'nested'); + expect(result.tree?.kind === 'directory' ? result.tree.children[0]?.path : '').toBe('scripts'); + expect(collectScriptDirectories(result.tree)).toEqual(['scripts', 'scripts/nested']); + expect(collectScriptFiles(result.tree).map((file) => file.path)).toEqual(['scripts/nested/two.js']); + expect(result.expandedPaths).toEqual(new Set(['scripts', 'scripts/nested'])); +}); + +test('directory search returns an empty root when nothing matches', () => { + const result = filterScriptDirectories(tree, 'missing'); + expect(result.tree).toMatchObject({ kind: 'directory', children: [] }); + expect(result.expandedPaths.size).toBe(0); +}); diff --git a/src/lib/scripts/tree.ts b/src/lib/scripts/tree.ts new file mode 100644 index 0000000..1b5aab1 --- /dev/null +++ b/src/lib/scripts/tree.ts @@ -0,0 +1,53 @@ +import type { ScriptTreeNode } from './types'; + +type ScriptFileNode = Extract; + +export function collectScriptFiles(node: ScriptTreeNode | null): ScriptFileNode[] { + if (!node) return []; + if (node.kind === 'file') return [node]; + return node.children.flatMap(collectScriptFiles); +} + +export function countScriptFiles(node: ScriptTreeNode | null): number { + return collectScriptFiles(node).length; +} + +export function collectScriptDirectories(node: ScriptTreeNode | null): string[] { + if (!node || node.kind === 'file') return []; + return node.children.flatMap((child) => + child.kind === 'directory' + ? [child.path, ...collectScriptDirectories(child)] + : [], + ); +} + +export function filterScriptDirectories(node: ScriptTreeNode | null, query: string): { + tree: ScriptTreeNode | null; + expandedPaths: Set; +} { + const normalized = query.trim().toLocaleLowerCase(); + if (!node || !normalized) return { tree: node, expandedPaths: new Set() }; + + const expandedPaths = new Set(); + const includeCompleteDirectory = (directory: Extract) => { + expandedPaths.add(directory.path); + for (const child of directory.children) { + if (child.kind === 'directory') includeCompleteDirectory(child); + } + }; + const filterDirectory = (directory: Extract, root = false): ScriptTreeNode | null => { + if (!root && directory.name.toLocaleLowerCase().includes(normalized)) { + includeCompleteDirectory(directory); + return directory; + } + const children = directory.children + .filter((child): child is Extract => child.kind === 'directory') + .map((child) => filterDirectory(child)) + .filter((child): child is ScriptTreeNode => child !== null); + if (!root && children.length === 0) return null; + if (!root) expandedPaths.add(directory.path); + return { ...directory, children }; + }; + + return { tree: filterDirectory(node as Extract, true), expandedPaths }; +} diff --git a/src/lib/scripts/types.ts b/src/lib/scripts/types.ts new file mode 100644 index 0000000..c9193c1 --- /dev/null +++ b/src/lib/scripts/types.ts @@ -0,0 +1,29 @@ +export type ScriptTreeNode = + | { kind: 'directory'; name: string; path: string; children: ScriptTreeNode[] } + | { kind: 'file'; name: string; path: string; size: number; mtimeMs: number; sha256: string }; + +export interface ScriptFile { + path: string; + content: string; + size: number; + mtimeMs: number; + sha256: string; +} + +export interface ScriptReference { + pointer: string; + path: string; + contentSha256: string; +} + +export interface ScriptManifest { + version: 1; + projectId: string; + projectName: string; + updatedAt: string; + references: ScriptReference[]; +} + +export interface ScriptApiErrorBody { + error: { code: string; message: string; details?: unknown }; +} diff --git a/src/lib/scripts/workspace.svelte.ts b/src/lib/scripts/workspace.svelte.ts new file mode 100644 index 0000000..30fe2d5 --- /dev/null +++ b/src/lib/scripts/workspace.svelte.ts @@ -0,0 +1,229 @@ +import type { ScriptFile, ScriptTreeNode } from './types'; +import { scriptApi, type ScriptApiError } from './api'; + +export interface EditableScriptFile extends ScriptFile { + savedContent: string; + dirty: boolean; + saving: boolean; +} + +export interface ScriptWorkspaceApi { + listTree(): Promise; + readFile(path: string): Promise; + writeFile(input: { path: string; content: string; expectedSha256: string | null }): Promise; + createFolder(path: string): Promise; + deleteFile(path: string, expectedSha256?: string): Promise; + deleteFolder(path: string, recursive: boolean): Promise; +} + +const DEFAULT_NEW_SCRIPT = '// 新脚本\n'; + +export function createScriptWorkspace(api: ScriptWorkspaceApi = scriptApi): ScriptWorkspace { + return new ScriptWorkspace(api); +} + +export class ScriptWorkspace { + tree = $state(null); + files = $state>(new Map()); + activePath = $state(''); + projectId = $state(''); + projectName = $state(''); + panelOpen = $state(false); + loading = $state(false); + serviceAvailable = $state(true); + contextRevision = $state(0); + warnings = $state>([]); + + readonly #api: ScriptWorkspaceApi; + #treeRequestGeneration = 0; + + constructor(api: ScriptWorkspaceApi) { + this.#api = api; + } + + get activeFile(): EditableScriptFile | null { + return this.files.get(this.activePath) ?? null; + } + + getContent(path: string): string | undefined { + return this.files.get(path)?.content; + } + + async refreshTree(): Promise { + const generation = ++this.#treeRequestGeneration; + const projectId = this.projectId; + try { + const tree = await this.#api.listTree(); + if (generation !== this.#treeRequestGeneration || projectId !== this.projectId) return; + this.tree = tree; + this.serviceAvailable = true; + } catch (error) { + if (generation !== this.#treeRequestGeneration || projectId !== this.projectId) return; + this.serviceAvailable = false; + throw error; + } + } + + async openFile(path: string): Promise { + if (this.files.has(path)) { + this.activePath = path; + return; + } + const file = await this.#api.readFile(path); + const editable: EditableScriptFile = { + ...file, + savedContent: file.content, + dirty: false, + saving: false, + }; + const next = new Map(this.files); + next.set(path, editable); + this.files = next; + this.activePath = path; + } + + updateActiveContent(content: string): void { + const file = this.activeFile; + if (!file) return; + const next = new Map(this.files); + next.set(this.activePath, { ...file, content, dirty: content !== file.savedContent }); + this.files = next; + } + + async writeFileForce(path: string, content: string): Promise { + let existing = this.files.get(path); + // 如果缓存中没有但文件可能存在于磁盘,先尝试读取以获取 sha256 + if (!existing) { + try { + const file = await this.#api.readFile(path); + existing = { ...file, savedContent: file.content, dirty: false, saving: false }; + } catch { + // 文件不存在,expectedSha256 传 null 让后端创建新文件 + } + } + if (!existing) { + const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + if (parent) { + try { + await this.#api.createFolder(parent); + } catch (error) { + if ((error as { code?: string })?.code !== 'ALREADY_EXISTS') throw error; + } + } + } + const written = await this.#api.writeFile({ path, content, expectedSha256: existing?.sha256 ?? null }); + const next = new Map(this.files); + next.set(path, { ...written, savedContent: written.content, dirty: false, saving: false }); + this.files = next; + this.activePath = path; + await this.refreshTree(); + } + + async #persist(file: EditableScriptFile): Promise { + const written = await this.#api.writeFile({ + path: file.path, + content: file.content, + expectedSha256: file.sha256, + }); + return { + ...written, + savedContent: written.content, + dirty: false, + saving: false, + }; + } + + async saveActive(): Promise { + const file = this.activeFile; + if (!file || !file.dirty || file.saving) return; + const next = new Map(this.files); + next.set(this.activePath, { ...file, saving: true }); + this.files = next; + try { + const updated = await this.#persist(file); + const after = new Map(this.files); + after.set(this.activePath, updated); + this.files = after; + } catch (error) { + const after = new Map(this.files); + after.set(this.activePath, { ...file, saving: false }); + this.files = after; + throw error; + } + } + + async flushDirty(): Promise { + for (const [path, file] of this.files) { + if (!file.dirty) continue; + const updated = await this.#persist(file); + const next = new Map(this.files); + next.set(path, updated); + this.files = next; + } + } + + async createFile(path: string, content: string = DEFAULT_NEW_SCRIPT): Promise { + const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''; + if (parent) { + try { + await this.#api.createFolder(parent); + } catch (error) { + if ((error as { code?: string })?.code !== 'ALREADY_EXISTS') throw error; + } + } + const created = await this.#api.writeFile({ path, content, expectedSha256: null }); + const editable: EditableScriptFile = { + ...created, + savedContent: created.content, + dirty: false, + saving: false, + }; + const next = new Map(this.files); + next.set(path, editable); + this.files = next; + this.activePath = path; + await this.refreshTree(); + } + + async createFolder(path: string): Promise { + await this.#api.createFolder(path); + await this.refreshTree(); + } + + async deleteFile(path: string): Promise { + const file = this.files.get(path); + await this.#api.deleteFile(path, file?.sha256); + const next = new Map(this.files); + next.delete(path); + this.files = next; + if (this.activePath === path) this.activePath = ''; + await this.refreshTree(); + } + + async deleteFolder(path: string): Promise { + await this.#api.deleteFolder(path, true); + const prefix = `${path}/`; + const next = new Map(this.files); + for (const filePath of next.keys()) { + if (filePath.startsWith(prefix)) next.delete(filePath); + } + this.files = next; + if (this.activePath.startsWith(prefix)) this.activePath = ''; + await this.refreshTree(); + } + + resetForProject(projectId: string, projectName: string): void { + this.#treeRequestGeneration += 1; + this.projectId = projectId; + this.projectName = projectName; + this.contextRevision += 1; + this.files = new Map(); + this.activePath = ''; + this.warnings = []; + this.tree = null; + } +} + +export const scriptWorkspace = createScriptWorkspace(); + +export type { ScriptApiError }; diff --git a/src/lib/scripts/workspace.test.js b/src/lib/scripts/workspace.test.js new file mode 100644 index 0000000..cfc3232 --- /dev/null +++ b/src/lib/scripts/workspace.test.js @@ -0,0 +1,263 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { createScriptWorkspace, scriptWorkspace, ScriptWorkspace } from './workspace.svelte'; + +function makeApi(overrides = {}) { + const writes = []; + const api = { + listTree: async () => ({ kind: 'directory', name: '', path: '', children: [] }), + readFile: async (path) => ({ path, content: `old-${path}`, sha256: `sha256:${path}`, size: 3, mtimeMs: 1 }), + writeFile: async (input) => { + writes.push(input); + return { path: input.path, content: input.content, sha256: `sha256:new-${input.path}`, size: 3, mtimeMs: 2 }; + }, + createFolder: async () => {}, + deleteFile: async () => {}, + deleteFolder: async () => {}, + ...overrides, + }; + return { api, writes }; +} + +test('shared workspace is initialized after the class declaration', () => { + expect(scriptWorkspace).toBeInstanceOf(ScriptWorkspace); + const source = readFileSync(new URL('./workspace.svelte.ts', import.meta.url), 'utf8'); + expect(source.indexOf('export class ScriptWorkspace')).toBeLessThan( + source.indexOf('export const scriptWorkspace'), + ); +}); + +test('edits stay in memory until explicitly saved', async () => { + const { api, writes } = makeApi(); + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/a.js'); + workspace.updateActiveContent('new'); + expect(workspace.activeFile.dirty).toBe(true); + expect(writes).toEqual([]); + await workspace.saveActive(); + expect(writes).toEqual([{ path: 'demo/a.js', content: 'new', expectedSha256: 'sha256:demo/a.js' }]); + expect(workspace.activeFile.dirty).toBe(false); + expect(workspace.activeFile.sha256).toBe('sha256:new-demo/a.js'); +}); + +test('opening the same file twice does not refetch', async () => { + let reads = 0; + const { api } = makeApi({ + readFile: async (path) => { reads += 1; return { path, content: 'c', sha256: 'sha256:c', size: 1, mtimeMs: 1 }; }, + }); + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/a.js'); + await workspace.openFile('demo/a.js'); + expect(reads).toBe(1); +}); + +test('updateActiveContent marks dirty only when content differs from saved', async () => { + const { api } = makeApi(); + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/a.js'); + workspace.updateActiveContent('old-demo/a.js'); + expect(workspace.activeFile.dirty).toBe(false); + workspace.updateActiveContent('changed'); + expect(workspace.activeFile.dirty).toBe(true); +}); + +test('flushDirty saves all dirty files and stops on a conflict', async () => { + const writes = []; + const api = { + listTree: async () => ({ kind: 'directory', name: '', path: '', children: [] }), + readFile: async (path) => ({ path, content: `old-${path}`, sha256: `sha256:${path}`, size: 3, mtimeMs: 1 }), + writeFile: async (input) => { + writes.push(input); + if (input.path === 'demo/b.js') { + const err = new Error('conflict'); + err.code = 'CONTENT_CONFLICT'; + throw err; + } + return { path: input.path, content: input.content, sha256: `sha256:new-${input.path}`, size: 3, mtimeMs: 2 }; + }, + createFolder: async () => {}, + deleteFile: async () => {}, + deleteFolder: async () => {}, + }; + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/a.js'); + workspace.updateActiveContent('new-a'); + await workspace.openFile('demo/b.js'); + workspace.updateActiveContent('new-b'); + + await expect(workspace.flushDirty()).rejects.toMatchObject({ code: 'CONTENT_CONFLICT' }); + expect(writes.map((item) => item.path)).toEqual(['demo/a.js', 'demo/b.js']); + expect(workspace.files.get('demo/a.js').dirty).toBe(false); + expect(workspace.files.get('demo/b.js').dirty).toBe(true); + expect(workspace.files.get('demo/b.js').content).toBe('new-b'); +}); + +test('createFile writes a default body and selects the new file', async () => { + const writes = []; + const api = { + listTree: async () => ({ kind: 'directory', name: '', path: '', children: [] }), + readFile: async () => { throw new Error('should not read'); }, + writeFile: async (input) => { writes.push(input); return { path: input.path, content: input.content, sha256: 'sha256:new', size: 3, mtimeMs: 2 }; }, + createFolder: async () => {}, + deleteFile: async () => {}, + deleteFolder: async () => {}, + }; + const workspace = createScriptWorkspace(api); + await workspace.createFile('demo/new.js'); + expect(writes).toEqual([{ path: 'demo/new.js', content: '// 新脚本\n', expectedSha256: null }]); + expect(workspace.activePath).toBe('demo/new.js'); + expect(workspace.activeFile.dirty).toBe(false); +}); + +test('createFile writes supplied extracted content and selects the new file', async () => { + const { api, writes } = makeApi(); + const workspace = createScriptWorkspace(api); + await workspace.createFile('demo/extracted.js', 'engine.notify("done")'); + expect(writes).toEqual([{ + path: 'demo/extracted.js', + content: 'engine.notify("done")', + expectedSha256: null, + }]); + expect(workspace.activeFile.content).toBe('engine.notify("done")'); + expect(workspace.activeFile.dirty).toBe(false); +}); + +test('createFile ensures a parent directory exists before writing', async () => { + const calls = []; + const { api } = makeApi({ + writeFile: async (input) => { + calls.push(`write:${input.path}`); + return { path: input.path, content: input.content, sha256: 'sha256:new', size: 3, mtimeMs: 2 }; + }, + createFolder: async (path) => { calls.push(`folder:${path}`); }, + }); + const workspace = createScriptWorkspace(api); + await workspace.createFile('demo/scripts/new.js'); + expect(calls).toEqual([ + 'folder:demo/scripts', + 'write:demo/scripts/new.js', + ]); +}); + +test('createFile ignores an existing parent directory', async () => { + const calls = []; + const { api } = makeApi({ + createFolder: async (path) => { + calls.push(`folder:${path}`); + const error = new Error('exists'); + error.code = 'ALREADY_EXISTS'; + throw error; + }, + writeFile: async (input) => { + calls.push(`write:${input.path}`); + return { path: input.path, content: input.content, sha256: 'sha256:new', size: 3, mtimeMs: 2 }; + }, + }); + const workspace = createScriptWorkspace(api); + await workspace.createFile('scripts/new.js'); + expect(calls).toEqual([ + 'folder:scripts', + 'write:scripts/new.js', + ]); +}); + +test('writeFileForce creates a missing draft directory before extracting a new script', async () => { + const calls = []; + const { api } = makeApi({ + readFile: async () => { + const error = new Error('missing'); + error.code = 'NOT_FOUND'; + throw error; + }, + createFolder: async (path) => { calls.push(`folder:${path}`); }, + writeFile: async (input) => { + calls.push(`write:${input.path}`); + return { path: input.path, content: input.content, sha256: 'sha256:new', size: 3, mtimeMs: 2 }; + }, + }); + const workspace = createScriptWorkspace(api); + + await workspace.writeFileForce('draft-project/worker.js', 'console.log("hello")'); + + expect(calls).toEqual([ + 'folder:draft-project', + 'write:draft-project/worker.js', + ]); + expect(workspace.activePath).toBe('draft-project/worker.js'); + expect(workspace.activeFile.content).toBe('console.log("hello")'); +}); + +test('deleteFile removes the file and clears active path when needed', async () => { + const deletes = []; + const api = { + listTree: async () => ({ kind: 'directory', name: '', path: '', children: [] }), + readFile: async (path) => ({ path, content: 'c', sha256: `sha256:${path}`, size: 1, mtimeMs: 1 }), + writeFile: async () => { throw new Error('no writes'); }, + createFolder: async () => {}, + deleteFile: async (path, sha) => { deletes.push({ path, sha }); }, + deleteFolder: async () => {}, + }; + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/a.js'); + await workspace.deleteFile('demo/a.js'); + expect(deletes).toEqual([{ path: 'demo/a.js', sha: 'sha256:demo/a.js' }]); + expect(workspace.files.has('demo/a.js')).toBe(false); + expect(workspace.activePath).toBe(''); +}); + +test('deleteFolder closes every open file below the deleted folder', async () => { + const { api } = makeApi(); + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/scripts/a.js'); + await workspace.openFile('demo/keep.js'); + await workspace.openFile('demo/scripts/b.js'); + await workspace.deleteFolder('demo/scripts'); + expect(workspace.files.has('demo/scripts/a.js')).toBe(false); + expect(workspace.files.has('demo/scripts/b.js')).toBe(false); + expect(workspace.files.has('demo/keep.js')).toBe(true); + expect(workspace.activePath).toBe(''); +}); + +test('getContent returns in-memory content for an open file', async () => { + const { api } = makeApi(); + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/a.js'); + workspace.updateActiveContent('dirty-code'); + expect(workspace.getContent('demo/a.js')).toBe('dirty-code'); + expect(workspace.getContent('demo/missing.js')).toBeUndefined(); +}); + +test('resetForProject clears open files and warnings', async () => { + const { api } = makeApi(); + const workspace = createScriptWorkspace(api); + await workspace.openFile('demo/a.js'); + workspace.warnings = [{ path: 'demo/a.js', reason: 'x' }]; + workspace.resetForProject('p1', 'demo'); + expect(workspace.projectId).toBe('p1'); + expect(workspace.projectName).toBe('demo'); + expect(workspace.files.size).toBe(0); + expect(workspace.activePath).toBe(''); + expect(workspace.warnings).toEqual([]); +}); + +test('ignores a tree response started for an earlier project', async () => { + let resolveFirst; + const first = new Promise((resolve) => { resolveFirst = resolve; }); + let calls = 0; + const { api } = makeApi({ + listTree: async () => { + calls += 1; + if (calls === 1) return first; + return { kind: 'directory', name: '', path: '', children: [{ kind: 'file', name: 'new.js', path: 'new.js' }] }; + }, + }); + const workspace = createScriptWorkspace(api); + workspace.resetForProject('old', 'Old'); + const staleRefresh = workspace.refreshTree(); + workspace.resetForProject('new', 'New'); + await workspace.refreshTree(); + resolveFirst({ kind: 'directory', name: '', path: '', children: [{ kind: 'file', name: 'old.js', path: 'old.js' }] }); + await staleRefresh; + + expect(workspace.tree.children.map((node) => node.path)).toEqual(['new.js']); +}); diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts new file mode 100644 index 0000000..053e340 --- /dev/null +++ b/src/lib/sha256.ts @@ -0,0 +1,60 @@ +const constants = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +function rotateRight(value: number, count: number): number { + return (value >>> count) | (value << (32 - count)); +} + +function fallback(input: Uint8Array): Uint8Array { + const paddedLength = Math.ceil((input.length + 9) / 64) * 64; + const padded = new Uint8Array(paddedLength); + padded.set(input); + padded[input.length] = 0x80; + const bitLength = BigInt(input.length) * 8n; + for (let index = 0; index < 8; index++) padded[paddedLength - 1 - index] = Number((bitLength >> BigInt(index * 8)) & 0xffn); + + const hash = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const words = new Uint32Array(64); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let index = 0; index < 16; index++) { + const start = offset + index * 4; + words[index] = ((padded[start] << 24) | (padded[start + 1] << 16) | (padded[start + 2] << 8) | padded[start + 3]) >>> 0; + } + for (let index = 16; index < 64; index++) { + const left = words[index - 15]; + const right = words[index - 2]; + words[index] = (words[index - 16] + (rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3)) + words[index - 7] + (rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10))) >>> 0; + } + let [a, b, c, d, e, f, g, h] = hash; + for (let index = 0; index < 64; index++) { + const temporary1 = (h + (rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)) + ((e & f) ^ (~e & g)) + constants[index] + words[index]) >>> 0; + const temporary2 = ((rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0; + h = g; g = f; f = e; e = (d + temporary1) >>> 0; d = c; c = b; b = a; a = (temporary1 + temporary2) >>> 0; + } + hash[0] = (hash[0] + a) >>> 0; hash[1] = (hash[1] + b) >>> 0; + hash[2] = (hash[2] + c) >>> 0; hash[3] = (hash[3] + d) >>> 0; + hash[4] = (hash[4] + e) >>> 0; hash[5] = (hash[5] + f) >>> 0; + hash[6] = (hash[6] + g) >>> 0; hash[7] = (hash[7] + h) >>> 0; + } + const output = new Uint8Array(32); + for (let index = 0; index < hash.length; index++) { + output[index * 4] = hash[index] >>> 24; + output[index * 4 + 1] = hash[index] >>> 16; + output[index * 4 + 2] = hash[index] >>> 8; + output[index * 4 + 3] = hash[index]; + } + return output; +} + +export async function sha256(input: Uint8Array): Promise { + const subtle = globalThis.crypto?.subtle; + return subtle ? new Uint8Array(await subtle.digest('SHA-256', input.slice().buffer as ArrayBuffer)) : fallback(input); +} diff --git a/src/lib/stores.svelte.ts b/src/lib/stores.svelte.ts new file mode 100644 index 0000000..90497f4 --- /dev/null +++ b/src/lib/stores.svelte.ts @@ -0,0 +1,650 @@ +export type Page = 'dashboard' | 'project' | 'images' | 'environment' | 'caches' | 'volumes' | 'settings' | 'session-detail'; + +export type RuntimeLevel = + | 'agents' + | 'project-runtime' + | 'latest-run' + | 'agent-sandboxes' + | 'sandbox-detail' + | 'schedulers' + | 'agent-detail' + | 'run-detail' + | 'scheduler-run-detail' + | 'session' + | 'loader-runs' + | 'loader-run-detail'; + +export interface RuntimeView { + level: RuntimeLevel; + agentName: string; + runId: string; + sessionId: string; + sandboxId?: string; + loaderId?: string; + // Non-URL, in-memory only: which cell to focus when entering a session from a LoaderRun event. + focusCellId?: string; +} + +import type { ProjectEntry, ToastMessage } from './types'; +import { EMPTY_YAML_TEMPLATE, yamlToSpec } from './yaml'; + +const LOCALSTORAGE_KEY = 'agent-compose-console'; + +function loadState(): { activeProjectId: string; editorContent: string } { + try { + const raw = localStorage.getItem(LOCALSTORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch { /* ignore */ } + return { activeProjectId: '', editorContent: '' }; +} + +function saveState(activeProjectId: string, editorContent: string) { + try { + localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({ activeProjectId, editorContent })); + } catch { /* ignore */ } +} + +export function defaultNavigationURL(pathname: string): string { + const eventPath = /(?:^|\/agent-compose)\/events\/[^/]+\/?$/.test(pathname); + return eventPath + ? `${pathname}#/project/new` + : `${pathname}?sandboxTab=files#/project/new`; +} + +// Per-project editor content cache. The backend stores the env-${VAR}-expanded +// spec, so without a local cache of the raw YAML, re-entering a project would +// show expanded values and lose `${VAR}` / `$ref:` references. These keys are +// the source of truth for what the user actually typed. +const PROJECT_EDITOR_PREFIX = 'editor:'; +const NEW_PROJECT_DRAFT_ID = '__new_project_draft__'; +const BROWSER_DRAFTS_KEY = 'editor:drafts:v1'; + +export interface BrowserDraft { + id: string; + name: string; + content: string; + updatedAt: string; +} + +function draftName(content: string): string { + return yamlToSpec(content).spec.name?.trim() || '未命名草稿'; +} + +function createDraftId(): string { + try { + if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID(); + } catch { /* use fallback */ } + return `draft-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function persistBrowserDrafts(drafts: BrowserDraft[]): void { + try { + localStorage.setItem(BROWSER_DRAFTS_KEY, JSON.stringify({ version: 1, drafts })); + } catch { /* ignore */ } +} + +export function loadBrowserDrafts(): BrowserDraft[] { + try { + const raw = localStorage.getItem(BROWSER_DRAFTS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed?.version === 1 && Array.isArray(parsed.drafts)) { + return parsed.drafts.filter((draft: unknown): draft is BrowserDraft => ( + !!draft && typeof draft === 'object' && + typeof (draft as BrowserDraft).id === 'string' && + typeof (draft as BrowserDraft).content === 'string' + )).map((draft: BrowserDraft) => ({ ...draft, name: draftName(draft.content) })); + } + } + const legacy = localStorage.getItem(projectEditorKey(NEW_PROJECT_DRAFT_ID)); + if (!legacy) return []; + const migrated = [{ id: createDraftId(), name: draftName(legacy), content: legacy, updatedAt: new Date().toISOString() }]; + persistBrowserDrafts(migrated); + localStorage.removeItem(projectEditorKey(NEW_PROJECT_DRAFT_ID)); + return migrated; + } catch { + return []; + } +} + +function projectEditorKey(id: string): string { + return `${PROJECT_EDITOR_PREFIX}${id}`; +} + +function loadProjectEditor(id: string): string | null { + try { + return localStorage.getItem(projectEditorKey(id)); + } catch { + return null; + } +} + +function saveProjectEditor(id: string, content: string): void { + try { + localStorage.setItem(projectEditorKey(id), content); + } catch { /* ignore */ } +} + +function removeProjectEditor(id: string): void { + try { + localStorage.removeItem(projectEditorKey(id)); + } catch { /* ignore */ } +} + +function safeDecode(s: string): string | null { + try { + return decodeURIComponent(s); + } catch { + return null; + } +} + +export function parseHash(hash: string): { + page: Page; + projectId: string; + runtimeView: RuntimeView; +} | null { + // Strip leading #/ or # + const path = hash.replace(/^#\/?/, ''); + if (!path) return null; + + const segments = path.split('/'); + + // #/dashboard + if (segments[0] === 'dashboard' && segments.length === 1) { + return { + page: 'dashboard', + projectId: '', + runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' }, + }; + } + + // #/settings + if (segments[0] === 'settings' && segments.length === 1) { + return { + page: 'settings', + projectId: '', + runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' }, + }; + } + + if (segments[0] === 'system' && segments.length === 2) { + const page = ({ images: 'images', environment: 'environment', capabilities: 'settings' } as const)[segments[1]]; + if (page) { + return { + page, + projectId: '', + runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' }, + }; + } + } + + if (segments[0] === 'resources' && segments[1] === 'images' && segments.length === 2) { + return { + page: 'images', + projectId: '', + runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' }, + }; + } + + if (segments[0] === 'resources' && segments[1] === 'caches' && segments.length === 2) { + return { page: 'caches', projectId: '', runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' } }; + } + + if (segments[0] === 'resources' && segments[1] === 'volumes' && segments.length === 2) { + return { page: 'volumes', projectId: '', runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' } }; + } + + // #/project/... + if (segments[0] === 'project' && segments.length >= 2) { + const projectId = segments[1]; + + // #/project/new + if (projectId === 'new' && segments.length === 2) { + return { + page: 'project', + projectId: '', + runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' }, + }; + } + + // #/project//agents + if (segments.length === 3 && segments[2] === 'agents') { + return { + page: 'project', + projectId, + runtimeView: { level: 'agents', agentName: '', runId: '', sessionId: '' }, + }; + } + + // #/project//latest-run + if (segments.length === 3 && segments[2] === 'latest-run') { + return { + page: 'project', + projectId, + runtimeView: { level: 'latest-run', agentName: '', runId: '', sessionId: '' }, + }; + } + + // #/project//runtime + if (segments.length === 3 && segments[2] === 'runtime') { + return { + page: 'project', + projectId, + runtimeView: { level: 'project-runtime', agentName: '', runId: '', sessionId: '' }, + }; + } + + // #/project//sandbox/ + if (segments.length === 4 && segments[2] === 'sandbox') { + const sandboxId = safeDecode(segments[3]); + if (sandboxId === null) return null; + return { + page: 'project', + projectId, + runtimeView: { level: 'sandbox-detail', sandboxId, agentName: '', runId: '', sessionId: '' }, + }; + } + + if (segments.length === 3 && segments[2] === 'schedulers') { + return { + page: 'project', projectId, + runtimeView: { level: 'schedulers', agentName: '', runId: '', sessionId: '' }, + }; + } + + // #/project//loader-runs + if (segments.length === 3 && segments[2] === 'loader-runs') { + return { + page: 'project', + projectId, + runtimeView: { level: 'loader-runs', agentName: '', runId: '', sessionId: '' }, + }; + } + + // #/project//loader-run//[/session/] + if (segments.length >= 5 && segments[2] === 'loader-run') { + const loaderId = safeDecode(segments[3]); + const runId = safeDecode(segments[4]); + if (loaderId === null || runId === null) return null; + + // #/project//loader-run// + if (segments.length === 5) { + return { + page: 'project', + projectId, + runtimeView: { level: 'loader-run-detail', agentName: '', loaderId, runId, sessionId: '' }, + }; + } + + // #/project//loader-run///session/ + if (segments.length === 7 && segments[5] === 'session') { + return { + page: 'project', + projectId, + runtimeView: { + level: 'session', + agentName: '', + loaderId, + runId, + sessionId: safeDecode(segments[6]) ?? '', + }, + }; + } + } + + // #/project//agent/[/...] + if (segments.length >= 4 && segments[2] === 'agent') { + const agentName = safeDecode(segments[3]); + if (agentName === null) return null; + + // #/project//agent/ + if (segments.length === 4) { + return { + page: 'project', + projectId, + runtimeView: { level: 'agent-detail', agentName, runId: '', sessionId: '' }, + }; + } + + // #/project//agent//sandboxes + if (segments.length === 5 && segments[4] === 'sandboxes') { + return { + page: 'project', + projectId, + runtimeView: { level: 'agent-sandboxes', agentName, runId: '', sessionId: '' }, + }; + } + + // #/project//agent//scheduler-run/ + if (segments.length === 6 && segments[4] === 'scheduler-run') { + const runId = safeDecode(segments[5]); + if (runId === null) return null; + return { + page: 'project', projectId, + runtimeView: { level: 'scheduler-run-detail', agentName, runId, sessionId: '' }, + }; + } + + // #/project//agent//session/ + if (segments.length === 6 && segments[4] === 'session') { + return { + page: 'project', + projectId, + runtimeView: { + level: 'session', + agentName, + runId: '', + sessionId: safeDecode(segments[5]) ?? '', + }, + }; + } + + // #/project//agent//run/[/session/] + if (segments.length >= 6 && segments[4] === 'run') { + const runId = safeDecode(segments[5]); + if (runId === null) return null; + + // #/project//agent//run/ + if (segments.length === 6) { + return { + page: 'project', + projectId, + runtimeView: { level: 'run-detail', agentName, runId, sessionId: '' }, + }; + } + + // #/project//agent//run//session/ + if (segments.length === 8 && segments[6] === 'session') { + return { + page: 'project', + projectId, + runtimeView: { + level: 'session', + agentName, + runId, + sessionId: safeDecode(segments[7]) ?? '', + }, + }; + } + } + } + } + + return null; +} + +export function buildHash(page: Page, projectId: string, rv: RuntimeView): string { + if (page === 'dashboard') return '#/dashboard'; + if (page === 'settings') return '#/system/capabilities'; + if (page === 'images') return '#/system/images'; + if (page === 'environment') return '#/system/environment'; + if (page === 'caches') return '#/resources/caches'; + if (page === 'volumes') return '#/resources/volumes'; + + // project page + if (!projectId) return '#/project/new'; + + if (rv.level === 'agents') return `#/project/${projectId}/agents`; + if (rv.level === 'project-runtime') return `#/project/${projectId}/runtime`; + if (rv.level === 'latest-run') return `#/project/${projectId}/latest-run`; + if (rv.level === 'schedulers') return `#/project/${projectId}/schedulers`; + if (rv.level === 'loader-runs') return `#/project/${projectId}/loader-runs`; + if (rv.level === 'loader-run-detail') { + return `#/project/${projectId}/loader-run/${encodeURIComponent(rv.loaderId ?? '')}/${encodeURIComponent(rv.runId)}`; + } + if (rv.level === 'agent-detail') return `#/project/${projectId}/agent/${encodeURIComponent(rv.agentName)}`; + if (rv.level === 'agent-sandboxes') return `#/project/${projectId}/agent/${encodeURIComponent(rv.agentName)}/sandboxes`; + if (rv.level === 'sandbox-detail') return `#/project/${projectId}/sandbox/${encodeURIComponent(rv.sandboxId ?? '')}`; + if (rv.level === 'run-detail') { + return `#/project/${projectId}/agent/${encodeURIComponent(rv.agentName)}/run/${encodeURIComponent(rv.runId)}`; + } + if (rv.level === 'scheduler-run-detail') { + return `#/project/${projectId}/agent/${encodeURIComponent(rv.agentName)}/scheduler-run/${encodeURIComponent(rv.runId)}`; + } + if (rv.level === 'session') { + if (rv.loaderId) { + return `#/project/${projectId}/loader-run/${encodeURIComponent(rv.loaderId)}/${encodeURIComponent(rv.runId)}/session/${encodeURIComponent(rv.sessionId)}`; + } + if (rv.runId) { + return `#/project/${projectId}/agent/${encodeURIComponent(rv.agentName)}/run/${encodeURIComponent(rv.runId)}/session/${encodeURIComponent(rv.sessionId)}`; + } + return `#/project/${projectId}/agent/${encodeURIComponent(rv.agentName)}/session/${encodeURIComponent(rv.sessionId)}`; + } + return '#/dashboard'; +} + +export class Store { + currentPage: Page = $state('dashboard'); + editorCollapsed = $state(false); + splitRatio = $state(45); + + projects: ProjectEntry[] = $state([]); + activeProjectId = $state(''); + editorContent = $state(''); + browserDrafts: BrowserDraft[] = $state(loadBrowserDrafts()); + activeDraftId = $state(''); + + dashboardOverview: { + runs?: { runningCount?: number; recentCount?: number; attentionCount?: number }; + } | null = $state(null); + + runtimeView: RuntimeView = $state({ + level: 'agents', + agentName: '', + runId: '', + sessionId: '', + }); + + runtimeRefreshVersion = $state(0); + sandboxReturnView: RuntimeView | null = null; + + toasts: ToastMessage[] = $state([]); + toastId = 0; + + _syncing = false; + + constructor() { + const saved = loadState(); + this.editorContent = saved.editorContent; + + // Try restoring navigation from URL hash first + const parsed = parseHash(window.location.hash); + if (parsed) { + this.currentPage = parsed.page; + const isNewProject = parsed.page === 'project' && !parsed.projectId; + this.activeProjectId = isNewProject ? '' : parsed.projectId || saved.activeProjectId; + if (isNewProject) this.editorContent = EMPTY_YAML_TEMPLATE; + this.runtimeView = parsed.runtimeView; + } else { + this.currentPage = 'project'; + this.activeProjectId = ''; + this.editorContent = EMPTY_YAML_TEMPLATE; + window.history.replaceState(null, '', defaultNavigationURL(window.location.pathname)); + } + + // Handle browser back/forward + window.addEventListener('hashchange', () => { + if (this._syncing) { + this._syncing = false; + return; + } + const parsed = parseHash(window.location.hash); + if (parsed) { + this.sandboxReturnView = null; + this.currentPage = parsed.page; + this.activeProjectId = parsed.page === 'project' && !parsed.projectId ? '' : parsed.projectId || this.activeProjectId; + if (parsed.page === 'project' && !parsed.projectId) this.activeDraftId = ''; + this.runtimeView = parsed.runtimeView; + } + }); + } + + // Persist editor content to localStorage whenever it changes + _persist = $effect.root(() => { + $effect(() => { + void this.editorContent; // track dependency + void this.activeProjectId; // track dependency + saveState(this.activeProjectId, this.editorContent); + }); + }); + + // Commit editor content produced by user editing/applying. Writes the raw + // (unexpanded) YAML to a per-project cache so re-entering the project keeps + // `${VAR}` / `$ref:` references instead of the backend-expanded snapshot. + // Only persists when a project is active, so unsaved new-project templates + // don't pollute an existing project's cache. + commitEditorContent(value: string) { + this.editorContent = value; + if (this.activeProjectId) saveProjectEditor(this.activeProjectId, value); + } + + loadProjectEditor(id: string): string | null { + return loadProjectEditor(id); + } + + removeProjectEditor(id: string) { + removeProjectEditor(id); + } + + saveProjectEditor(id: string, content: string) { + saveProjectEditor(id, content); + } + + saveEditorDraft() { + if (this.activeProjectId) { + saveProjectEditor(this.activeProjectId, this.editorContent); + return { ok: true as const, draft: null }; + } + const name = draftName(this.editorContent); + const duplicate = this.browserDrafts.find((draft) => draft.id !== this.activeDraftId && draft.name === name); + if (duplicate) return { ok: false as const, reason: 'duplicate-name' as const, name }; + const existing = this.browserDrafts.find((draft) => draft.id === this.activeDraftId); + const draft: BrowserDraft = { + id: existing?.id || createDraftId(), + name, + content: this.editorContent, + updatedAt: new Date().toISOString(), + }; + this.browserDrafts = existing + ? this.browserDrafts.map((item) => item.id === draft.id ? draft : item) + : [...this.browserDrafts, draft]; + this.activeDraftId = draft.id; + persistBrowserDrafts(this.browserDrafts); + return { ok: true as const, draft }; + } + + loadEditorDraft(id = this.activeDraftId): string | null { + return this.activeProjectId ? loadProjectEditor(this.activeProjectId) : this.browserDrafts.find((draft) => draft.id === id)?.content || null; + } + + beginEditorDraft() { + this.activeProjectId = ''; + this.activeDraftId = ''; + this.editorContent = EMPTY_YAML_TEMPLATE; + } + + selectEditorDraft(id: string): BrowserDraft | undefined { + const draft = this.browserDrafts.find((item) => item.id === id); + if (!draft) return undefined; + this.activeProjectId = ''; + this.activeDraftId = draft.id; + this.editorContent = draft.content; + return draft; + } + + removeEditorDraft(id = this.activeDraftId) { + if (!id) return; + this.browserDrafts = this.browserDrafts.filter((draft) => draft.id !== id); + persistBrowserDrafts(this.browserDrafts); + if (this.activeDraftId === id) this.activeDraftId = ''; + } + + syncHash() { + const hash = buildHash(this.currentPage, this.activeProjectId, this.runtimeView); + if (window.location.hash === hash) return; + this._syncing = true; + window.location.hash = hash; + } + + navigateTo(level: RuntimeLevel, opts: Partial> = {}) { + if (level === 'sandbox-detail') { + if (this.runtimeView.level !== 'sandbox-detail') this.sandboxReturnView = { ...this.runtimeView }; + this.runtimeView = { + level, + agentName: '', + runId: '', + sessionId: '', + sandboxId: opts.sandboxId ?? '', + loaderId: undefined, + focusCellId: '', + }; + this.syncHash(); + return; + } + this.sandboxReturnView = null; + this.runtimeView = { + level, + agentName: opts.agentName ?? this.runtimeView.agentName, + runId: opts.runId ?? this.runtimeView.runId, + sessionId: opts.sessionId ?? this.runtimeView.sessionId, + loaderId: opts.loaderId ?? this.runtimeView.loaderId, + focusCellId: opts.focusCellId ?? this.runtimeView.focusCellId, + sandboxId: undefined, + }; + // Clear focusCellId when navigating to a non-session target so it doesn't leak. + if (level !== 'session') { + this.runtimeView.focusCellId = opts.focusCellId ?? ''; + } + this.syncHash(); + } + + navigateBack() { + const v = this.runtimeView; + if (v.level === 'agent-detail') { + this.runtimeView = { level: 'agents', agentName: '', runId: '', sessionId: '' }; + } else if (v.level === 'project-runtime' || v.level === 'latest-run' || v.level === 'schedulers') { + this.runtimeView = { level: 'agents', agentName: '', runId: '', sessionId: '' }; + } else if (v.level === 'agent-sandboxes') { + this.runtimeView = { level: 'agent-detail', agentName: v.agentName, runId: '', sessionId: '' }; + } else if (v.level === 'sandbox-detail' && this.sandboxReturnView) { + this.runtimeView = this.sandboxReturnView; + this.sandboxReturnView = null; + } else if (v.level === 'loader-runs') { + this.runtimeView = { level: 'agents', agentName: '', runId: '', sessionId: '' }; + } else if (v.level === 'loader-run-detail') { + this.runtimeView = { level: 'loader-runs', agentName: '', runId: '', sessionId: '' }; + } else if (v.level === 'run-detail' || v.level === 'scheduler-run-detail') { + this.runtimeView = { level: 'agent-detail', agentName: v.agentName, runId: '', sessionId: '' }; + } else if (v.level === 'session') { + if (v.loaderId) { + this.runtimeView = { level: 'loader-run-detail', agentName: '', loaderId: v.loaderId, runId: v.runId, sessionId: '' }; + } else if (v.runId) { + this.runtimeView = { level: 'run-detail', agentName: v.agentName, runId: v.runId, sessionId: '' }; + } else { + this.runtimeView = { level: 'agent-detail', agentName: v.agentName, runId: '', sessionId: '' }; + } + } + this.syncHash(); + } + + goTo(page: Page) { + this.currentPage = page; + this.syncHash(); + } + + addToast(message: string, level: 'info' | 'error' | 'success' = 'info') { + const id = ++this.toastId; + this.toasts.push({ id, message, level }); + setTimeout(() => { + const idx = this.toasts.findIndex(t => t.id === id); + if (idx >= 0) this.toasts.splice(idx, 1); + }, 5000); + } + + triggerRuntimeRefresh() { + this.runtimeRefreshVersion++; + } +} + +export const store = new Store(); diff --git a/src/lib/stores.test.ts b/src/lib/stores.test.ts new file mode 100644 index 0000000..0085f2b --- /dev/null +++ b/src/lib/stores.test.ts @@ -0,0 +1,127 @@ +// @ts-nocheck -- Executed by Bun, while svelte-check intentionally has no Bun test globals. +import { Window } from 'happy-dom'; + +const browser = new Window(); +Object.assign(globalThis, { window: browser, localStorage: browser.localStorage }); +const { buildHash, defaultNavigationURL, loadBrowserDrafts, parseHash, store } = await import('./stores.svelte'); + +const idle = { level: 'agents' as const, agentName: '', runId: '', sessionId: '' }; + +test('does not force the files tab onto standalone event URLs', () => { + expect(defaultNavigationURL('/agent-compose/events/evt-1')).toBe('/agent-compose/events/evt-1#/project/new'); + expect(defaultNavigationURL('/events/evt-1')).toBe('/events/evt-1#/project/new'); + expect(defaultNavigationURL('/')).toBe('/?sandboxTab=files#/project/new'); +}); + +test('round-trips cache and volume resource routes', () => { + expect(parseHash('#/resources/caches')?.page).toBe('caches'); + expect(parseHash('#/resources/volumes')?.page).toBe('volumes'); + expect(buildHash('caches', '', idle)).toBe('#/resources/caches'); + expect(buildHash('volumes', '', idle)).toBe('#/resources/volumes'); +}); + +test('uses canonical system management routes and accepts legacy aliases', () => { + expect(parseHash('#/system/images')?.page).toBe('images'); + expect(parseHash('#/system/environment')?.page).toBe('environment'); + expect(parseHash('#/system/capabilities')?.page).toBe('settings'); + expect(parseHash('#/resources/images')?.page).toBe('images'); + expect(parseHash('#/settings')?.page).toBe('settings'); + expect(buildHash('images', '', idle)).toBe('#/system/images'); + expect(buildHash('environment', '', idle)).toBe('#/system/environment'); + expect(buildHash('settings', '', idle)).toBe('#/system/capabilities'); +}); + +test('round-trips an Agent-owned Scheduler Run detail route', () => { + const view = { level: 'scheduler-run-detail' as const, agentName: 'radar/collector', runId: 'loader-run-1', sessionId: '' }; + const hash = buildHash('project', 'project-1', view); + expect(hash).toBe('#/project/project-1/agent/radar%2Fcollector/scheduler-run/loader-run-1'); + expect(parseHash(hash)?.runtimeView).toEqual(view); +}); + +test('commitEditorContent caches the raw YAML under editor:', () => { + localStorage.clear(); + store.activeProjectId = 'proj-cache'; + const raw = 'name: P\nenv:\n ANTHROPIC_BASE_URL:\n value: ${LLM_API_ENDPOINT}\n'; + store.commitEditorContent(raw); + // The ${VAR} reference must be preserved verbatim in the per-project cache. + expect(store.loadProjectEditor('proj-cache')).toBe(raw); +}); + +test('commitEditorContent does not cache when no project is active (unsaved new project)', () => { + localStorage.clear(); + store.activeProjectId = ''; + store.commitEditorContent('name: P\n'); + // No per-project key should be written, so an existing project's cache is not polluted. + expect(store.loadProjectEditor('')).toBeNull(); +}); + +test('saveProjectEditor / loadProjectEditor / removeProjectEditor round-trip', () => { + localStorage.clear(); + expect(store.loadProjectEditor('proj-rt')).toBeNull(); + store.saveProjectEditor('proj-rt', 'yaml-body'); + expect(store.loadProjectEditor('proj-rt')).toBe('yaml-body'); + store.removeProjectEditor('proj-rt'); + expect(store.loadProjectEditor('proj-rt')).toBeNull(); +}); + +test('saveEditorDraft persists multiple new-project drafts with stable identities', () => { + localStorage.clear(); + store.browserDrafts = []; + store.activeDraftId = ''; + store.activeProjectId = ''; + store.editorContent = 'name: first-draft\n'; + + const first = store.saveEditorDraft(); + store.beginEditorDraft(); + store.editorContent = 'name: second-draft\n'; + const second = store.saveEditorDraft(); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + expect(store.browserDrafts.map((draft) => draft.name)).toEqual(['first-draft', 'second-draft']); + expect(store.browserDrafts[0].id).not.toBe(store.browserDrafts[1].id); +}); + +test('saveEditorDraft updates the selected draft and rejects another draft with the same name', () => { + localStorage.clear(); + store.browserDrafts = []; + store.activeDraftId = ''; + store.activeProjectId = ''; + store.editorContent = 'name: first-draft\n# value: 1\n'; + const first = store.saveEditorDraft(); + store.beginEditorDraft(); + store.editorContent = 'name: second-draft\n'; + store.saveEditorDraft(); + + store.selectEditorDraft(first.draft.id); + store.editorContent = 'name: first-draft\n# value: 2\n'; + expect(store.saveEditorDraft().ok).toBe(true); + expect(store.browserDrafts.find((draft) => draft.id === first.draft.id)?.content).toContain('value: 2'); + + store.editorContent = 'name: second-draft\n# duplicate\n'; + expect(store.saveEditorDraft()).toEqual({ ok: false, reason: 'duplicate-name', name: 'second-draft' }); +}); + +test('removeEditorDraft deletes only the specified draft', () => { + localStorage.clear(); + store.browserDrafts = []; + store.activeDraftId = ''; + store.activeProjectId = ''; + store.editorContent = 'name: keep\n'; + const keep = store.saveEditorDraft(); + store.beginEditorDraft(); + store.editorContent = 'name: remove\n'; + const remove = store.saveEditorDraft(); + + store.removeEditorDraft(remove.draft.id); + + expect(store.browserDrafts.map((draft) => draft.id)).toEqual([keep.draft.id]); +}); + +test('loadBrowserDrafts migrates the legacy singleton draft', () => { + localStorage.clear(); + localStorage.setItem('editor:__new_project_draft__', 'name: legacy-draft\n'); + + expect(loadBrowserDrafts()).toMatchObject([{ name: 'legacy-draft', content: 'name: legacy-draft\n' }]); + expect(localStorage.getItem('editor:__new_project_draft__')).toBeNull(); +}); diff --git a/src/lib/svelte-warning-regressions.test.js b/src/lib/svelte-warning-regressions.test.js new file mode 100644 index 0000000..e31495d --- /dev/null +++ b/src/lib/svelte-warning-regressions.test.js @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'bun:test'; + +function source(relativePath) { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +test('the pointer-only resizer is not exposed as a keyboard-focusable control', () => { + const resizer = source('../components/Resizer.svelte'); + assert.doesNotMatch(resizer, /class="resizer"[\s\S]*?tabindex=/); +}); + +test('run modal marks one-time prop prefills as intentionally untracked', () => { + const modal = source('../modals/RunAgentModal.svelte'); + assert.match(modal, /untrack\(\(\) => prefilledAgent\)/); + assert.match(modal, /untrack\(\(\) => \(\{[\s\S]*input: prefilledPrompt,[\s\S]*\}\)\)/); +}); + +test('run detail does not retain selectors for removed event markup', () => { + const detail = source('../views/runtime/RunDetailView.svelte'); + assert.doesNotMatch(detail, /\.inline-data-heading span:last-child/); + assert.doesNotMatch(detail, /\.event-row\s*>|\.event-row code|\.event-row p/); +}); diff --git a/src/lib/toolbar-actions.test.js b/src/lib/toolbar-actions.test.js new file mode 100644 index 0000000..8cbaf86 --- /dev/null +++ b/src/lib/toolbar-actions.test.js @@ -0,0 +1,1320 @@ +import { describe, expect, test } from 'bun:test'; +import { ProjectChangeAction, RunSource, RunStatus, SchedulerRunStatus, StartRunRequest } from '../gen/agentcompose/v2/agentcompose_pb'; +import { + deleteProject, + getAppliedProjectId, + runProjectAgents, + runProjectAgentsIfCurrent, + runYamlBatch, + previewProject, + prepareProjectPreview, + consumePendingApply, + createPreviewGeneration, + saveProject, + probeProjectRuntimeActivity, + softPauseProject, + stopProjectRuns, +} from './toolbar-actions'; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function deferredStream() { + const values = []; + const waiters = []; + let closed = false; + return { + push(value) { + const resolve = waiters.shift(); + if (resolve) resolve({ value, done: false }); + else values.push(value); + }, + close() { + closed = true; + for (const resolve of waiters.splice(0)) resolve({ done: true }); + }, + iterable: { + [Symbol.asyncIterator]() { return this; }, + next() { + if (values.length) return Promise.resolve({ value: values.shift(), done: false }); + if (closed) return Promise.resolve({ done: true }); + return new Promise((resolve) => waiters.push(resolve)); + }, + }, + }; +} + +async function* streamOf(...chunks) { + yield* chunks; +} + +function runDetails(finalStatus = RunStatus.SUCCEEDED) { + const calls = new Map(); + return async (request) => { + const count = calls.get(request.runId) ?? 0; + calls.set(request.runId, count + 1); + return { run: { summary: { runId: request.runId, status: count === 0 ? RunStatus.RUNNING : finalStatus } } }; + }; +} + +describe('runYamlBatch', () => { + const agents = [{ name: 'a', prompt: 'A' }, { name: 'b', prompt: 'B' }]; + const callbacks = (events) => ({ + onStarting: (name) => events.push(`starting:${name}`), + onStarted: (name, run) => events.push(`started:${name}:${run.runId}`), + onChunk: (name, chunk) => events.push(`chunk:${name}:${chunk.data}`), + onFinished: (name, status) => events.push(`finished:${name}:${status}`), + onStartFailed: (name) => events.push(`failed:${name}`), + onTrackingError: (name) => events.push(`tracking-error:${name}`), + }); + + test('starts the next agent only after the previous log stream is final', async () => { + const events = []; + const first = deferredStream(); + const client = { + startRun: async ({ run }) => ({ started: true, run: { runId: `run-${run.agentName}`, status: RunStatus.PENDING } }), + followRunLogs: ({ runId }) => runId === 'run-a' + ? first.iterable + : streamOf({ data: 'B', offset: 1n, runStatus: RunStatus.SUCCEEDED, isFinal: true }), + getRun: runDetails(), + }; + const pending = runYamlBatch({ projectId: 'p1', agents, client, isCurrent: () => true, ...callbacks(events) }); + await tick(); + expect(events).toEqual(['starting:a', 'started:a:run-a']); + first.push({ data: 'A', offset: 1n, runStatus: RunStatus.SUCCEEDED, isFinal: true }); + first.close(); + await pending; + expect(events).toEqual([ + 'starting:a', 'started:a:run-a', 'chunk:a:A', `finished:a:${RunStatus.SUCCEEDED}`, + 'starting:b', 'started:b:run-b', 'chunk:b:B', `finished:b:${RunStatus.SUCCEEDED}`, + ]); + }); + + test('sends a generated StartRunRequest with the complete manual run input', async () => { + const requests = []; + await runYamlBatch({ + projectId: 'project-full', agents: [{ name: 'agent-full', prompt: 'prompt-full' }], isCurrent: () => true, + ...callbacks([]), + client: { + startRun: async (request) => { + requests.push(request); + return { started: true, run: { runId: 'run-full' } }; + }, + followRunLogs: () => streamOf({ offset: 1n, isFinal: true, runStatus: RunStatus.SUCCEEDED }), + getRun: runDetails(), + }, + }); + expect(requests[0]).toBeInstanceOf(StartRunRequest); + expect(requests[0].run).toMatchObject({ + projectId: 'project-full', + agentName: 'agent-full', + prompt: 'prompt-full', + source: RunSource.MANUAL, + }); + }); + + test('uses non-empty response warnings when the run is not started', async () => { + const errors = []; + await runYamlBatch({ + projectId: 'p1', agents: [agents[0]], isCurrent: () => true, + ...callbacks([]), + onStartFailed: (_name, error) => errors.push(error), + client: { + startRun: async () => ({ started: false, warnings: ['', 'capacity exhausted', 'retry later'] }), + followRunLogs: () => streamOf(), + }, + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toBe('capacity exhausted; retry later'); + }); + + test('uses the precise fallback when a started response omits its run ID', async () => { + const errors = []; + await runYamlBatch({ + projectId: 'p1', agents: [agents[0]], isCurrent: () => true, + ...callbacks([]), + onStartFailed: (_name, error) => errors.push(error), + client: { + startRun: async () => ({ started: true, run: {}, warnings: [] }), + followRunLogs: () => streamOf(), + }, + }); + expect(errors[0].message).toBe('运行未返回 Run ID'); + }); + + test('treats a missing run ID as a start failure and continues', async () => { + const events = []; + await runYamlBatch({ + projectId: 'p1', agents, isCurrent: () => true, ...callbacks(events), + client: { + startRun: async ({ run }) => run.agentName === 'a' ? { started: true } : { started: true, run: { runId: 'run-b' } }, + followRunLogs: () => streamOf({ data: '', offset: 1n, isFinal: true, runStatus: RunStatus.SUCCEEDED }), + getRun: runDetails(), + }, + }); + expect(events).toEqual(['starting:a', 'failed:a', 'starting:b', 'started:b:run-b', 'chunk:b:', `finished:b:${RunStatus.SUCCEEDED}`]); + }); + + test('continues to the next agent when startRun rejects', async () => { + const events = []; + await runYamlBatch({ + projectId: 'p1', agents, isCurrent: () => true, ...callbacks(events), + client: { + startRun: async ({ run }) => { + if (run.agentName === 'a') throw new Error('start unavailable'); + return { started: true, run: { runId: 'run-b' } }; + }, + followRunLogs: () => streamOf({ data: 'B', offset: 1n, isFinal: true, runStatus: RunStatus.SUCCEEDED }), + getRun: async () => ({ run: { summary: { runId: 'run-b', status: RunStatus.SUCCEEDED } } }), + }, + }); + expect(events).toContain('failed:a'); + expect(events).toContain('started:b:run-b'); + }); + + test('abort and generation invalidation suppress old callbacks and later starts', async () => { + for (const invalidate of ['abort', 'generation']) { + const events = []; + const first = deferredStream(); + const controller = new AbortController(); + let current = true; + const pending = runYamlBatch({ + projectId: 'p1', agents, signal: controller.signal, isCurrent: () => current, ...callbacks(events), + client: { + startRun: async () => ({ started: true, run: { runId: 'run-a' } }), + followRunLogs: () => first.iterable, + getRun: runDetails(), + }, + }); + await tick(); + if (invalidate === 'abort') controller.abort(); + else current = false; + first.push({ data: 'stale', offset: 1n, isFinal: true }); + first.close(); + await pending; + expect(events).toEqual(['starting:a', 'started:a:run-a']); + } + }); + + test('propagates the exact start offset and suppresses duplicate chunks', async () => { + const events = []; + const requests = []; + await runYamlBatch({ + projectId: 'p1', agents: [agents[0]], startOffset: 7n, isCurrent: () => true, ...callbacks(events), + client: { + startRun: async () => ({ started: true, run: { runId: 'run-a' } }), + followRunLogs: (request) => { + requests.push(request); + return streamOf( + { data: 'duplicate', offset: 7n, isFinal: false }, + { data: 'new', offset: 8n, isFinal: true }, + ); + }, + getRun: runDetails(), + }, + }); + expect(requests[0].startOffset).toBe(7n); + expect(events.filter((event) => event.startsWith('chunk:'))).toEqual(['chunk:a:', 'chunk:a:new']); + }); + + test('delivers terminal status even when final chunk has duplicate offset zero', async () => { + for (const status of [RunStatus.FAILED, RunStatus.CANCELED]) { + const chunks = []; + const finished = []; + await runYamlBatch({ + projectId: 'p1', agents: [agents[0]], isCurrent: () => true, + ...callbacks([]), + onChunk: (_name, chunk) => chunks.push(chunk), + onFinished: (_name, status) => finished.push(status), + client: { + startRun: async () => ({ started: true, run: { runId: 'run-a', status: RunStatus.RUNNING } }), + getRun: runDetails(status), + followRunLogs: () => streamOf({ data: '', offset: 0n, runStatus: status, isFinal: true }), + }, + }); + expect(chunks).toHaveLength(1); + expect(chunks[0].runStatus).toBe(status); + expect(finished).toEqual([status]); + } + }); + + test('reconciles a naturally ended stream before starting the next agent', async () => { + const events = []; + await runYamlBatch({ + projectId: 'p1', agents, isCurrent: () => true, ...callbacks(events), + client: { + startRun: async ({ run }) => ({ started: true, run: { runId: `run-${run.agentName}` } }), + getRun: runDetails(), + followRunLogs: () => streamOf({ data: 'last', offset: 1n, runStatus: RunStatus.SUCCEEDED, isFinal: false }), + }, + }); + expect(events.filter(event => event.startsWith('finished:'))).toEqual([ + `finished:a:${RunStatus.SUCCEEDED}`, `finished:b:${RunStatus.SUCCEEDED}`, + ]); + }); + + test('follows logs when the initial GetRun is temporarily unavailable', async () => { + const events = []; + let getCalls = 0; + await runYamlBatch({ + projectId: 'p1', agents: [agents[0]], isCurrent: () => true, ...callbacks(events), + client: { + startRun: async () => ({ started: true, run: { runId: 'run-a', status: RunStatus.PENDING } }), + getRun: async () => { + if (getCalls++ === 0) throw new Error('not visible yet'); + return { run: { summary: { runId: 'run-a', status: RunStatus.SUCCEEDED } } }; + }, + followRunLogs: () => streamOf({ data: 'done', offset: 1n, runStatus: RunStatus.SUCCEEDED, isFinal: true }), + }, + }); + expect(events).toContain('chunk:a:done'); + expect(events).toContain(`finished:a:${RunStatus.SUCCEEDED}`); + }); + + test('uses a terminal final chunk when final GetRun fails and continues', async () => { + const events = []; + let getCalls = 0; + await runYamlBatch({ + projectId: 'p1', agents, isCurrent: () => true, ...callbacks(events), + client: { + startRun: async ({ run }) => ({ started: true, run: { runId: `run-${run.agentName}`, status: RunStatus.RUNNING } }), + getRun: async (request) => { + if (getCalls++ % 2 === 0) return { run: { summary: { runId: request.runId, status: RunStatus.RUNNING } } }; + throw new Error('final unavailable'); + }, + followRunLogs: () => streamOf({ offset: 0n, runStatus: RunStatus.FAILED, isFinal: true }), + }, + }); + expect(events.filter(event => event.startsWith('finished:'))).toEqual([ + `finished:a:${RunStatus.FAILED}`, `finished:b:${RunStatus.FAILED}`, + ]); + expect(events).not.toContain('tracking-error:a'); + }); + + test('stops later agents when a nonterminal stream ends and final GetRun fails', async () => { + const events = []; + let calls = 0; + await runYamlBatch({ + projectId: 'p1', agents, isCurrent: () => true, ...callbacks(events), + client: { + startRun: async ({ run }) => ({ started: true, run: { runId: `run-${run.agentName}`, status: RunStatus.RUNNING } }), + getRun: async () => calls++ === 0 + ? { run: { summary: { runId: 'run-a', status: RunStatus.RUNNING } } } + : Promise.reject(new Error('final unavailable')), + followRunLogs: () => streamOf({ offset: 1n, runStatus: RunStatus.RUNNING, isFinal: false }), + }, + }); + expect(events).toContain('tracking-error:a'); + expect(events).not.toContain('starting:b'); + }); + + test('stops the batch when a tracked stream disconnects after start', async () => { + const events = []; + await runYamlBatch({ + projectId: 'p1', agents, isCurrent: () => true, ...callbacks(events), + client: { + startRun: async ({ run }) => ({ started: true, run: { runId: `run-${run.agentName}` } }), + followRunLogs: async function* () { throw new Error('disconnected'); }, + getRun: runDetails(), + }, + }); + expect(events).toEqual(['starting:a', 'started:a:run-a', 'tracking-error:a']); + }); +}); + +const validYaml = `name: demo +agents: + alpha: + system_prompt: You are alpha + beta: {} +`; + +describe('saveProject', () => { + test('runs dependency preflight before dry-run Apply', async () => { + const order = []; + await prepareProjectPreview({ + mode: 'save', + currentProjectId: '', + editorContent: validYaml, + projects: [], + fallbackSpecHash: '', + prepare: async () => { order.push('prepare'); return { yamlText: validYaml }; }, + preflight: async (prepared) => { order.push(`preflight:${prepared.yamlText.includes('name: demo')}`); }, + client: { applyProject: async () => { order.push('apply'); return { changes: [], revision: { specHash: 'hash' } }; } }, + isCurrent: () => true, + }); + + expect(order).toEqual(['prepare', 'preflight:true', 'apply']); + }); + + test('blocks dry-run Apply when dependency preflight rejects', async () => { + let applyCalls = 0; + await expect(prepareProjectPreview({ + mode: 'save', + currentProjectId: '', + editorContent: validYaml, + projects: [], + fallbackSpecHash: '', + prepare: async () => ({ yamlText: validYaml }), + preflight: async () => { throw new Error('镜像不存在'); }, + client: { applyProject: async () => { applyCalls += 1; return { changes: [] }; } }, + isCurrent: () => true, + })).rejects.toThrow('镜像不存在'); + expect(applyCalls).toBe(0); + }); + + test('drops a deferred preview across an A to B to A switch during script preparation', async () => { + let releasePrepare; + let applyCalls = 0; + const generation = createPreviewGeneration(() => {}); + const token = generation.begin(); + const preparing = prepareProjectPreview({ + mode: 'save', + currentProjectId: 'project-a', + editorContent: 'name: project-a\nagents: {}\n', + projects: [{ summary: { projectId: 'project-a', name: 'project-a', sourcePath: '/a/agent-compose.yml', specHash: 'hash-a' } }], + fallbackSpecHash: '', + prepare: () => new Promise((resolve) => { releasePrepare = resolve; }), + client: { applyProject: async () => { applyCalls++; return { changes: [] }; } }, + isCurrent: () => generation.isCurrent(token), + }); + + generation.invalidate(); // A -> B + generation.invalidate(); // B -> A + releasePrepare({ yamlText: 'name: project-a\nagents: {}\n', references: [] }); + + expect(await preparing).toBeUndefined(); + expect(applyCalls).toBe(0); + }); + + test('permanently clears a published preview when context switches away and back', () => { + const state = { pending: {}, changes: [{}], issues: [{}], unchanged: true, show: true }; + const generation = createPreviewGeneration(() => { + state.pending = undefined; state.changes = []; state.issues = []; state.unchanged = false; state.show = false; + }); + const token = generation.begin(); + generation.invalidate(); // A -> B clears + generation.invalidate(); // B -> A must not revive + expect(generation.isCurrent(token)).toBe(false); + expect(state).toEqual({ pending: undefined, changes: [], issues: [], unchanged: false, show: false }); + }); + + test('does not restore a failed apply after its generation switches', async () => { + let restored; + const generation = createPreviewGeneration(() => {}); + const token = generation.begin(); + const failed = { apply: async () => { generation.invalidate(); throw new Error('conflict'); } }; + let slot = failed; + await expect(consumePendingApply({ + take: () => { const value = slot; slot = undefined; return value; }, + restore: (value) => { if (generation.isCurrent(token)) restored = value; }, + })).rejects.toThrow('conflict'); + expect(restored).toBeUndefined(); + expect(slot).toBeUndefined(); + }); + + test('atomically consumes pending apply so double confirmation sends one RPC and restores on failure', async () => { + let calls = 0; + const pending = { apply: async () => { calls++; await Promise.resolve(); return 'ok'; } }; + let slot = pending; + const access = { take: () => { const value = slot; slot = undefined; return value; }, restore: (value) => { slot = value; } }; + const [first, second] = await Promise.all([consumePendingApply(access), consumePendingApply(access)]); + expect(first).toBe('ok'); + expect(second).toBeUndefined(); + expect(calls).toBe(1); + + const failed = { apply: async () => { throw new Error('conflict'); } }; + slot = failed; + await expect(consumePendingApply(access)).rejects.toThrow('conflict'); + expect(slot).toBe(failed); + }); + + test('previews without a stale hash and applies with the normalized dry-run hash', async () => { + const requests = []; + const client = { + applyProject: async (request) => { + requests.push(request); + return request.dryRun + ? { applied: false, unchanged: false, changes: [{ name: 'alpha', action: ProjectChangeAction.CREATED }], issues: [], revision: { specHash: 'hash-after-edit' } } + : { applied: true, unchanged: false, changes: [{ name: 'alpha' }] }; + }, + }; + const options = { + currentProjectId: 'current', + expectedSpecHash: 'hash-before-edit', + projects: [{ summary: { projectId: 'current', name: 'demo', sourcePath: '/srv/demo/agent-compose.yml' } }], + }; + + const preview = await previewProject(validYaml, client, options); + expect(requests).toHaveLength(1); + expect(requests[0].dryRun).toBe(true); + expect(requests[0].expectedSpecHash).toBe(''); + expect(requests[0].source.composePath).toBe('/srv/demo/agent-compose.yml'); + expect(preview.response.changes).toHaveLength(1); + expect(preview.response.unchanged).toBe(false); + expect(preview.response.changes[0].action).toBe(ProjectChangeAction.CREATED); + + await preview.apply(); + expect(requests).toHaveLength(2); + expect(requests[1].dryRun).toBe(false); + expect(requests[1].expectedSpecHash).toBe('hash-after-edit'); + expect(requests[1].source.composePath).toBe(requests[0].source.composePath); + expect(requests[1].spec).toBe(requests[0].spec); + }); + + test('treats dry-run created resources as unchanged when the saved and preview hashes match', async () => { + const response = { + applied: false, + unchanged: false, + changes: Array.from({ length: 13 }, (_, index) => ({ + action: ProjectChangeAction.CREATED, + resourceType: 'agent', + name: `resource-${index + 1}`, + })), + revision: { specHash: 'same-hash' }, + }; + + const preview = await previewProject(validYaml, { + applyProject: async () => response, + }, { + currentProjectId: 'current', + expectedSpecHash: 'same-hash', + projects: [{ summary: { projectId: 'current', name: 'demo', specHash: 'same-hash' } }], + }); + + expect(preview.response).toBe(response); + expect(preview.response.unchanged).toBe(true); + expect(preview.response.changes).toHaveLength(13); + expect(preview.response.changes.every((change) => change.action === ProjectChangeAction.UNCHANGED)).toBe(true); + }); + + test('parses and applies YAML and returns the applied agent names', async () => { + const requests = []; + const response = { applied: true, unchanged: false, changes: [] }; + + const result = await saveProject(validYaml, { + applyProject: async (request) => { + requests.push(request); + return response; + }, + }); + + expect(requests).toHaveLength(1); + expect(requests[0].source.composePath).toBe('agent-compose.yml'); + expect(result.response).toBe(response); + expect(result.agentNames).toEqual(['alpha', 'beta']); + expect(result.agents).toEqual([ + { name: 'alpha', prompt: 'You are alpha', hasScheduler: false }, + { name: 'beta', prompt: '', hasScheduler: false }, + ]); + }); + + test('marks agents that define a scheduler in the applied YAML', async () => { + const result = await saveProject(`name: scheduled-demo +agents: + scheduled: + system_prompt: You are scheduled + scheduler: + enabled: false + triggers: [] + manual: {} +`, { + applyProject: async () => ({ applied: true }), + }); + + expect(result.agents).toEqual([ + { name: 'scheduled', prompt: 'You are scheduled', hasScheduler: true }, + { name: 'manual', prompt: '', hasScheduler: false }, + ]); + }); + + test('rejects invalid YAML without applying', async () => { + let applyCalls = 0; + + await expect(saveProject('agents: [', { + applyProject: async () => { + applyCalls++; + return { applied: true }; + }, + })).rejects.toThrow('YAML 解析错误'); + + expect(applyCalls).toBe(0); + }); + + test('uses preparedYaml (expanded script) instead of editorContent ($ref) when applying', async () => { + const requests = []; + const editorYaml = `name: demo +agents: + worker: + scheduler: + script: $ref:demo/a.js +`; + const preparedYaml = `name: demo +agents: + worker: + scheduler: + script: full-inline-code +`; + + await saveProject(editorYaml, { + applyProject: async (request) => { + requests.push(request); + return { applied: true, unchanged: false, changes: [] }; + }, + }, { + currentProjectId: '', + projects: [], + preparedYaml, + }); + + expect(requests).toHaveLength(1); + const appliedScript = requests[0].spec.agents[0].scheduler?.script; + expect(appliedScript).toBe('full-inline-code'); + }); + + test('rejects a response that is neither applied nor unchanged', async () => { + await expect(saveProject(validYaml, { + applyProject: async () => ({ applied: false, unchanged: false }), + })).rejects.toThrow('保存未生效'); + }); + + test('rejects a duplicate new project before applying', async () => { + let applyCalls = 0; + + await expect(saveProject(validYaml, { + applyProject: async () => { + applyCalls++; + return { applied: true }; + }, + }, { + currentProjectId: '', + projects: [{ summary: { projectId: 'existing', name: ' demo ' } }], + })).rejects.toThrow('智能体应用名称 "demo" 已存在,请修改名称'); + + expect(applyCalls).toBe(0); + }); + + test('allows an existing project to keep its own name', async () => { + let applyCalls = 0; + + await saveProject(validYaml, { + applyProject: async () => { + applyCalls++; + return { applied: true }; + }, + }, { + currentProjectId: 'current', + projects: [{ summary: { projectId: 'current', name: 'demo' } }], + }); + + expect(applyCalls).toBe(1); + }); + + test('allows a legacy project ID to match its current list entry', async () => { + const requests = []; + const hash = '31ccba9d49e51ff2093b2a9ec7f69d6676f72a1e952569cf99c567007d19581f'; + + await saveProject(validYaml, { + applyProject: async (request) => { + requests.push(request); + return { applied: true }; + }, + }, { + currentProjectId: `sha256:${hash}`, + projects: [{ + summary: { + projectId: hash, + name: 'demo', + sourcePath: '/projects/tech-radar/agent-compose.yml', + }, + }], + }); + + expect(requests).toHaveLength(1); + expect(requests[0].source.composePath).toBe('/projects/tech-radar/agent-compose.yml'); + }); + + test('uses the existing source path when saving the current project', async () => { + const requests = []; + + await saveProject(validYaml, { + applyProject: async (request) => { + requests.push(request); + return { applied: true }; + }, + }, { + currentProjectId: 'current', + projects: [{ + summary: { + projectId: 'current', + name: 'demo', + sourcePath: '/projects/tech-radar/agent-compose.yml', + }, + }], + }); + + expect(requests).toHaveLength(1); + expect(requests[0].source.composePath).toBe('/projects/tech-radar/agent-compose.yml'); + }); + + test('identifies a legacy sha256 ID superseded by the applied project ID', async () => { + const legacyId = 'sha256:31ccba9d49e51ff2093b2a9ec7f69d6676f72a1e952569cf99c567007d19581f'; + + const result = await saveProject(validYaml, { + applyProject: async () => ({ + applied: true, + project: { summary: { projectId: legacyId.slice('sha256:'.length) } }, + }), + }, { + currentProjectId: legacyId, + projects: [{ summary: { projectId: legacyId, name: 'demo' } }], + }); + + expect(result.supersededProjectId).toBe(legacyId); + }); + + test('does not supersede an unrelated current project ID', async () => { + const result = await saveProject(validYaml, { + applyProject: async () => ({ + applied: true, + project: { summary: { projectId: 'different-project' } }, + }), + }, { + currentProjectId: 'current', + projects: [{ summary: { projectId: 'current', name: 'demo' } }], + }); + + expect(result.supersededProjectId).toBe(''); + }); + + test('rejects renaming an existing project to another project name', async () => { + await expect(saveProject(validYaml, { + applyProject: async () => ({ applied: true }), + }, { + currentProjectId: 'current', + projects: [ + { summary: { projectId: 'current', name: 'old-name' } }, + { summary: { projectId: 'other', name: 'demo' } }, + ], + })).rejects.toThrow('智能体应用名称 "demo" 已存在,请修改名称'); + }); +}); + +describe('runProjectAgentsIfCurrent', () => { + test('suppresses deferred old-project run side effects after A to B to A invalidation', async () => { + let releaseRun; + const effects = []; + const generation = createPreviewGeneration(() => {}); + const token = generation.begin(); + const running = runProjectAgentsIfCurrent({ + projectId: 'project-a', + agents: [{ name: 'alpha', prompt: '' }, { name: 'beta', prompt: '' }], + client: { runAgent: () => new Promise((resolve) => { releaseRun = resolve; }) }, + isCurrent: () => generation.isCurrent(token), + onNavigate: () => effects.push('navigate'), + onStarted: (name) => effects.push(`started:${name}`), + onFailed: (name) => effects.push(`failed:${name}`), + onSettled: () => effects.push('refresh'), + }); + expect(effects).toEqual(['navigate']); + effects.length = 0; + generation.invalidate(); // A -> B + generation.invalidate(); // B -> A + releaseRun({}); + await running; + expect(effects).toEqual([]); + }); + + test('suppresses a deferred rejection from an invalid generation', async () => { + let rejectRun; + const effects = []; + const generation = createPreviewGeneration(() => {}); + const token = generation.begin(); + const running = runProjectAgentsIfCurrent({ + projectId: 'project-a', agents: [{ name: 'alpha', prompt: '' }], + client: { runAgent: () => new Promise((_, reject) => { rejectRun = reject; }) }, + isCurrent: () => generation.isCurrent(token), + onNavigate: () => effects.push('navigate'), onStarted: () => effects.push('started'), + onFailed: () => effects.push('failed'), onSettled: () => effects.push('refresh'), + }); + effects.length = 0; + generation.invalidate(); + rejectRun(new Error('old failure')); + await running; + expect(effects).toEqual([]); + }); +}); +describe('deleteProject', () => { + test('removes the selected project and stops its running sessions', async () => { + const requests = []; + + await deleteProject('project-1', { + removeProject: async (request) => { requests.push(request); }, + }); + + expect(requests).toHaveLength(1); + expect(requests[0].project.projectId).toBe('project-1'); + expect(requests[0].stopRunningSandboxes).toBe(true); + expect(requests[0].removeHistory).toBe(false); + }); + + test('can remove a superseded project without stopping shared running sessions', async () => { + const requests = []; + + await deleteProject('legacy-project', { + removeProject: async (request) => { requests.push(request); }, + }, { stopRunningSessions: false }); + + expect(requests).toHaveLength(1); + expect(requests[0].stopRunningSandboxes).toBe(false); + }); +}); + +describe('getAppliedProjectId', () => { + test('uses the project returned by Apply instead of a stale selected project', () => { + const response = { project: { summary: { projectId: 'project-new' } } }; + + expect(getAppliedProjectId(response, 'project-removed')).toBe('project-new'); + }); + + test('falls back to the selected project when Apply omits its project', () => { + expect(getAppliedProjectId({}, 'project-current')).toBe('project-current'); + }); +}); + +describe('stopProjectRuns', () => { + test('stops pending and running runs without removing the project', async () => { + const listedStatuses = []; + const stoppedRuns = []; + + const result = await stopProjectRuns({ + projectId: 'project-1', + client: { + listRuns: async (request) => { + listedStatuses.push(request.status); + if (request.status === RunStatus.PENDING) { + return { runs: [{ runId: 'pending-1' }] }; + } + return { runs: [{ runId: 'running-1' }] }; + }, + stopRun: async (request) => { + stoppedRuns.push({ runId: request.runId, reason: request.reason }); + }, + }, + }); + + expect(listedStatuses).toEqual([RunStatus.PENDING, RunStatus.RUNNING]); + expect(stoppedRuns).toEqual([ + { runId: 'pending-1', reason: 'stopped from web console' }, + { runId: 'running-1', reason: 'stopped from web console' }, + ]); + expect(result).toEqual({ attempted: 2, stopped: 2, failed: 0, errors: [] }); + }); + + test('paginates run discovery before stopping so no active run is skipped', async () => { + const firstPage = Array.from({ length: 200 }, (_, index) => ({ runId: `run-${index}` })); + const listOffsets = []; + let stopCalls = 0; + + const result = await stopProjectRuns({ + projectId: 'project-1', + client: { + listRuns: async (request) => { + listOffsets.push([request.status, request.offset]); + if (request.status === RunStatus.PENDING) return { runs: [] }; + return { runs: request.offset === 0 ? firstPage : [{ runId: 'run-200' }] }; + }, + stopRun: async () => { stopCalls++; }, + }, + }); + + expect(listOffsets).toEqual([ + [RunStatus.PENDING, 0], + [RunStatus.RUNNING, 0], + [RunStatus.RUNNING, 200], + ]); + expect(stopCalls).toBe(201); + expect(result).toEqual({ attempted: 201, stopped: 201, failed: 0, errors: [] }); + }); + + test('continues stopping other runs and reports failures', async () => { + const result = await stopProjectRuns({ + projectId: 'project-1', + client: { + listRuns: async (request) => request.status === RunStatus.PENDING + ? { runs: [{ runId: 'pending-1' }] } + : { runs: [{ runId: 'running-1' }] }, + stopRun: async (request) => { + if (request.runId === 'pending-1') throw new Error('boom'); + }, + }, + }); + + expect(result).toEqual({ + attempted: 2, + stopped: 1, + failed: 1, + errors: ['Failed to stop run pending-1: boom'], + }); + }); +}); + +describe('softPauseProject', () => { + function client(overrides = {}) { + return { + listSchedulers: async () => ({ schedulers: [] }), + getScheduler: async () => ({ scheduler: { enabled: false } }), + setSchedulerEnabled: async ({ enabled }) => ({ scheduler: { enabled }, overridden: true }), + listSchedulerRuns: async () => ({ runs: [] }), + stopSchedulerRun: async () => {}, + listRuns: async () => ({ runs: [] }), + stopRun: async () => {}, + listSandboxes: async () => ({ sandboxes: [] }), + stopSandbox: async () => {}, + removeProject: async () => { throw new Error('RemoveProject must not be called'); }, + ...overrides, + }; + } + + test('exhausts cursor pages and mutates only matching enabled schedulers and running sandboxes', async () => { + const schedulerRequests = []; + const sandboxRequests = []; + const disabled = []; + const stoppedSandboxes = []; + let initialSandboxInventory = true; + const api = client({ + listSchedulers: async (request) => { + schedulerRequests.push({ limit: request.limit, cursor: request.cursor }); + return request.cursor === '' ? { + schedulers: [ + { projectId: 'project-1', agentName: 'enabled', enabled: true }, + { projectId: 'project-1', agentName: 'disabled', enabled: false }, + { projectId: 'project-10', agentName: 'unrelated', enabled: true }, + ], + nextCursor: 'scheduler-page-2', + } : { + schedulers: [{ projectId: 'project-1', agentName: 'page-two', enabled: true }], + }; + }, + setSchedulerEnabled: async (request) => { + disabled.push({ + projectId: request.project?.projectId, + agentName: request.agentName, + enabled: request.enabled, + }); + return { scheduler: { enabled: request.enabled }, overridden: true }; + }, + getScheduler: async ({ agentName }) => ({ scheduler: { enabled: agentName !== 'disabled' } }), + listSandboxes: async (request) => { + sandboxRequests.push({ limit: request.limit, cursor: request.cursor }); + if (!initialSandboxInventory) return { sandboxes: [] }; + return request.cursor === '' ? { + sandboxes: [ + { sandboxId: 'running', projectId: 'project-1', status: ' running ' }, + { sandboxId: 'stopped', projectId: 'project-1', status: 'STOPPED' }, + { sandboxId: 'unrelated', projectId: 'project-10', status: 'RUNNING' }, + ], + nextCursor: 'sandbox-page-2', + } : { + sandboxes: [{ sandboxId: 'page-two', projectId: 'project-1', status: 'RUNNING' }], + }; + }, + stopSandbox: async (request) => { + stoppedSandboxes.push(request.sandboxId); + if (request.sandboxId === 'page-two') initialSandboxInventory = false; + }, + }); + + const result = await softPauseProject({ projectId: 'project-1', client: api }); + + expect(schedulerRequests.slice(0, 2)).toEqual([ + { limit: 500, cursor: '' }, + { limit: 500, cursor: 'scheduler-page-2' }, + ]); + expect(schedulerRequests).toHaveLength(2); + expect(sandboxRequests).toEqual([ + { limit: 500, cursor: '' }, + { limit: 500, cursor: 'sandbox-page-2' }, + { limit: 500, cursor: '' }, + { limit: 500, cursor: '' }, + ]); + expect(disabled).toEqual([ + { projectId: 'project-1', agentName: 'enabled', enabled: false }, + { projectId: 'project-1', agentName: 'page-two', enabled: false }, + ]); + expect(stoppedSandboxes).toEqual(['running', 'page-two']); + expect(result).toEqual({ + schedulers: { attempted: 2, succeeded: 2, failed: 0, errors: [] }, + schedulerRuns: { attempted: 0, succeeded: 0, failed: 0, errors: [] }, + runs: { attempted: 0, succeeded: 0, failed: 0, errors: [] }, + sandboxes: { attempted: 2, succeeded: 2, failed: 0, errors: [] }, + failed: 0, + }); + }); + + test('stops sandboxes whose project id lives only in the project tag (webhook/scheduler runs)', async () => { + const stoppedSandboxes = []; + let sandboxRunning = true; + const api = client({ + listSandboxes: async () => ({ sandboxes: sandboxRunning ? [ + // webhook/scheduler triggered loader runs expose an empty projectId; + // the project id only appears in the `project` tag. + { sandboxId: 'tag-only', projectId: '', status: 'RUNNING', tags: [{ name: 'project', value: 'project-1' }] }, + { sandboxId: 'unrelated', projectId: '', status: 'RUNNING', tags: [{ name: 'project', value: 'project-9' }] }, + ] : [] }), + stopSandbox: async ({ sandboxId }) => { stoppedSandboxes.push(sandboxId); sandboxRunning = false; }, + }); + + const result = await softPauseProject({ projectId: 'project-1', client: api }); + + expect(stoppedSandboxes).toEqual(['tag-only']); + expect(result.sandboxes).toEqual({ attempted: 1, succeeded: 1, failed: 0, errors: [] }); + }); + + test('isolates mutation failures and maps the existing run-stage result', async () => { + const stoppedRuns = []; + const stoppedSandboxes = []; + const api = client({ + listSchedulers: async () => ({ schedulers: [ + { projectId: 'project-1', agentName: 'bad', enabled: true }, + { projectId: 'project-1', agentName: 'good', enabled: true }, + ] }), + setSchedulerEnabled: async ({ agentName }) => { + if (agentName === 'bad') throw new Error('scheduler mutation failed'); + return { scheduler: { enabled: false }, overridden: true }; + }, + getScheduler: async () => ({ scheduler: { enabled: true } }), + listRuns: async ({ status }) => status === RunStatus.PENDING + ? { runs: [{ runId: 'bad-run' }] } + : { runs: [{ runId: 'good-run' }] }, + stopRun: async ({ runId }) => { + stoppedRuns.push(runId); + if (runId === 'bad-run') throw new Error('run mutation failed'); + }, + listSandboxes: async () => ({ sandboxes: [ + { sandboxId: 'bad-box', projectId: 'project-1', status: 'RUNNING' }, + { sandboxId: 'good-box', projectId: 'project-1', status: 'RUNNING' }, + ] }), + stopSandbox: async ({ sandboxId }) => { + stoppedSandboxes.push(sandboxId); + if (sandboxId === 'bad-box') throw new Error('sandbox mutation failed'); + }, + }); + + const result = await softPauseProject({ projectId: 'project-1', client: api }); + + expect(stoppedRuns).toEqual(['bad-run', 'good-run']); + expect(stoppedSandboxes).toEqual(['bad-box', 'good-box']); + expect(result.schedulers).toMatchObject({ attempted: 2, succeeded: 1, failed: 1 }); + expect(result.schedulers.errors[0]).toContain('scheduler mutation failed'); + expect(result.runs).toEqual({ + attempted: 2, + succeeded: 1, + failed: 1, + errors: ['Failed to stop run bad-run: run mutation failed'], + }); + expect(result.sandboxes).toMatchObject({ attempted: 2, succeeded: 1, failed: 1 }); + expect(result.sandboxes.errors[0]).toContain('sandbox mutation failed'); + expect(result.failed).toBe(3); + }); + + test('reports repeated cursors as list failures and continues through all later stages', async () => { + const events = []; + const api = client({ + listSchedulers: async ({ cursor }) => { + events.push(`schedulers:${cursor}`); + return { schedulers: [], nextCursor: 'same-cursor' }; + }, + listRuns: async ({ status }) => { + events.push(`runs:${status}`); + throw new Error('run listing failed'); + }, + listSandboxes: async ({ cursor }) => { + events.push(`sandboxes:${cursor}`); + return { sandboxes: [], nextCursor: 'sandbox-repeat' }; + }, + }); + + const result = await softPauseProject({ projectId: 'project-1', client: api }); + + expect(events).toEqual([ + 'schedulers:', 'schedulers:same-cursor', + `runs:${RunStatus.PENDING}`, + 'sandboxes:', 'sandboxes:sandbox-repeat', + ]); + expect(result.schedulers).toMatchObject({ attempted: 0, failed: 1 }); + expect(result.schedulers.errors[0]).toContain('same-cursor'); + expect(result.schedulerRuns).toMatchObject({ attempted: 0, failed: 0 }); + expect(result.runs).toMatchObject({ attempted: 0, failed: 1 }); + expect(result.runs.errors[0]).toContain('run listing failed'); + expect(result.sandboxes).toMatchObject({ attempted: 0, failed: 1 }); + expect(result.sandboxes.errors[0]).toContain('sandbox-repeat'); + expect(result.failed).toBe(3); + }); + + test('reports a Scheduler failure when the backend does not confirm it disabled', async () => { + const result = await softPauseProject({ + projectId: 'project-1', + client: client({ + listSchedulers: async () => ({ schedulers: [ + { projectId: 'project-1', agentName: 'worker', enabled: true }, + ] }), + getScheduler: async () => ({ scheduler: { enabled: true } }), + listSchedulerRuns: async () => ({ runs: [] }), + setSchedulerEnabled: async () => ({ scheduler: { enabled: true }, overridden: false }), + }), + }); + + expect(result.schedulers).toMatchObject({ attempted: 1, succeeded: 0, failed: 1 }); + expect(result.schedulers.errors[0]).toContain('未确认已禁用'); + expect(result.failed).toBe(1); + }); + + test('disables an effectively enabled Scheduler even when its list summary is stale', async () => { + const disabled = []; + const result = await softPauseProject({ + projectId: 'project-1', + client: client({ + listSchedulers: async () => ({ schedulers: [ + { projectId: 'project-1', agentName: 'collector', enabled: false }, + ] }), + getScheduler: async () => ({ scheduler: { enabled: true } }), + setSchedulerEnabled: async ({ agentName, enabled }) => { + disabled.push({ agentName, enabled }); + return { scheduler: { enabled: false }, overridden: true }; + }, + }), + }); + + expect(disabled).toEqual([{ agentName: 'collector', enabled: false }]); + expect(result.schedulers).toEqual({ attempted: 1, succeeded: 1, failed: 0, errors: [] }); + expect(result.failed).toBe(0); + }); + + test('stops a late run created after the first pause scan before reporting success', async () => { + const stoppedRuns = []; + let pendingScans = 0; + const result = await softPauseProject({ + projectId: 'project-1', + client: client({ + listRuns: async ({ status }) => { + if (status !== RunStatus.PENDING) return { runs: [] }; + pendingScans++; + return pendingScans === 2 ? { runs: [{ runId: 'late-run' }] } : { runs: [] }; + }, + stopRun: async ({ runId }) => { stoppedRuns.push(runId); }, + }), + }); + + expect(pendingScans).toBeGreaterThanOrEqual(3); + expect(stoppedRuns).toEqual(['late-run']); + expect(result.runs).toMatchObject({ attempted: 1, succeeded: 1, failed: 0 }); + expect(result.failed).toBe(0); + }); + + test('paginates and stops running Scheduler Runs, including one that appears in a later convergence round', async () => { + const requests = []; + const stopped = []; + let firstRunActive = true; + let rootScans = 0; + const result = await softPauseProject({ + projectId: 'project-1', + client: client({ + listSchedulers: async () => ({ schedulers: [ + { projectId: 'project-1', agentName: 'collector' }, + ] }), + listSchedulerRuns: async ({ project, agentName, cursor }) => { + requests.push({ projectId: project?.projectId, agentName, cursor }); + if (cursor === 'page-2') { + return { runs: firstRunActive ? [{ runId: 'first', status: SchedulerRunStatus.RUNNING }] : [] }; + } + rootScans++; + if (rootScans === 1) return { + runs: [{ runId: 'finished', status: SchedulerRunStatus.SUCCEEDED }], + nextCursor: 'page-2', + }; + if (rootScans === 2) return { runs: [{ runId: 'late', status: SchedulerRunStatus.RUNNING }] }; + return { runs: [] }; + }, + stopSchedulerRun: async ({ project, runId, reason }) => { + stopped.push({ projectId: project?.projectId, runId, reason }); + if (runId === 'first') firstRunActive = false; + }, + }), + }); + + expect(requests.slice(0, 2)).toEqual([ + { projectId: 'project-1', agentName: '', cursor: '' }, + { projectId: 'project-1', agentName: '', cursor: 'page-2' }, + ]); + expect(stopped).toEqual([ + { projectId: 'project-1', runId: 'first', reason: 'project paused from web console' }, + { projectId: 'project-1', runId: 'late', reason: 'project paused from web console' }, + ]); + expect(result.schedulerRuns).toEqual({ attempted: 2, succeeded: 2, failed: 0, errors: [] }); + expect(rootScans).toBeGreaterThanOrEqual(4); + expect(result.failed).toBe(0); + }); + + test('reports a repeated Scheduler Run cursor as a stage failure', async () => { + const result = await softPauseProject({ + projectId: 'project-1', + client: client({ + listSchedulers: async () => ({ schedulers: [{ projectId: 'project-1', agentName: 'collector' }] }), + listSchedulerRuns: async () => ({ runs: [], nextCursor: 'repeat' }), + }), + }); + + expect(result.schedulerRuns.failed).toBe(1); + expect(result.schedulerRuns.errors[0]).toContain('repeat'); + expect(result.failed).toBe(1); + }); + + test('reports failure instead of success when project activity never converges', async () => { + let scan = 0; + const stoppedRuns = []; + const result = await softPauseProject({ + projectId: 'project-1', + client: client({ + listRuns: async ({ status }) => status === RunStatus.PENDING + ? { runs: [{ runId: `run-${++scan}` }] } + : { runs: [] }, + stopRun: async ({ runId }) => { stoppedRuns.push(runId); }, + }), + }); + + expect(stoppedRuns).toHaveLength(6); + expect(result.runs.failed).toBe(1); + expect(result.runs.errors).toContain('项目暂停在 6 轮检查后仍未收敛'); + expect(result.failed).toBe(1); + }); +}); + +describe('probeProjectRuntimeActivity', () => { + test('detects a running Scheduler Run even when its Scheduler is disabled', async () => { + const result = await probeProjectRuntimeActivity({ + projectId: 'project-1', + client: { + listSchedulers: async () => ({ schedulers: [{ projectId: 'project-1', agentName: 'worker' }] }), + getScheduler: async () => ({ scheduler: { enabled: false } }), + listSchedulerRuns: async () => ({ runs: [{ runId: 'active', status: SchedulerRunStatus.RUNNING }] }), + listRuns: async () => ({ runs: [] }), + listSandboxes: async () => ({ sandboxes: [] }), + }, + }); + + expect(result).toEqual({ scheduler: false, schedulerRun: true, run: false, sandbox: false, active: true }); + }); + test('detects each pausable resource from live paginated project state', async () => { + const scheduler = await probeProjectRuntimeActivity({ + projectId: 'project-1', + client: { + listSchedulers: async ({ cursor }) => cursor + ? { schedulers: [{ projectId: 'project-1', agentName: 'worker', enabled: true }] } + : { schedulers: [{ projectId: 'other', enabled: true }], nextCursor: 'next' }, + getScheduler: async () => ({ scheduler: { enabled: true } }), + listSchedulerRuns: async () => ({ runs: [] }), + listRuns: async () => ({ runs: [] }), + listSandboxes: async () => ({ sandboxes: [] }), + }, + }); + expect(scheduler).toEqual({ scheduler: true, schedulerRun: false, run: false, sandbox: false, active: true }); + + const run = await probeProjectRuntimeActivity({ + projectId: 'project-1', + client: { + listSchedulers: async () => ({ schedulers: [] }), + listSchedulerRuns: async () => ({ runs: [] }), + listRuns: async ({ status }) => ({ runs: status === RunStatus.RUNNING ? [{ projectId: 'project-1' }] : [] }), + listSandboxes: async () => ({ sandboxes: [] }), + }, + }); + expect(run).toEqual({ scheduler: false, schedulerRun: false, run: true, sandbox: false, active: true }); + + const sandbox = await probeProjectRuntimeActivity({ + projectId: 'project-1', + client: { + listSchedulers: async () => ({ schedulers: [] }), + listSchedulerRuns: async () => ({ runs: [] }), + listRuns: async () => ({ runs: [] }), + listSandboxes: async () => ({ sandboxes: [ + { projectId: 'other', status: 'RUNNING' }, + { projectId: 'project-1', status: ' running ' }, + ] }), + }, + }); + expect(sandbox).toEqual({ scheduler: false, schedulerRun: false, run: false, sandbox: true, active: true }); + }); + + test('returns inactive when all matching project resources are already paused', async () => { + const result = await probeProjectRuntimeActivity({ + projectId: 'project-1', + client: { + listSchedulers: async () => ({ schedulers: [{ projectId: 'project-1', enabled: false }] }), + getScheduler: async () => ({ scheduler: { enabled: false }, overridden: true }), + listSchedulerRuns: async () => ({ runs: [] }), + listRuns: async () => ({ runs: [] }), + listSandboxes: async () => ({ sandboxes: [{ projectId: 'project-1', status: 'STOPPED' }] }), + }, + }); + expect(result).toEqual({ scheduler: false, schedulerRun: false, run: false, sandbox: false, active: false }); + }); + + test('uses effective GetScheduler state instead of stale YAML state from ListSchedulers', async () => { + const result = await probeProjectRuntimeActivity({ + projectId: 'project-1', + client: { + listSchedulers: async () => ({ schedulers: [{ projectId: 'project-1', agentName: 'worker', enabled: true }] }), + getScheduler: async () => ({ scheduler: { enabled: false }, overridden: true }), + listSchedulerRuns: async () => ({ runs: [] }), + listRuns: async () => ({ runs: [] }), + listSandboxes: async () => ({ sandboxes: [] }), + }, + }); + expect(result.scheduler).toBe(false); + expect(result.active).toBe(false); + }); +}); + + +describe('runProjectAgents', () => { + test('ordinary-runs every applied agent including agents with schedulers', async () => { + const requests = []; + const started = []; + let settled = 0; + await runProjectAgents({ + projectId: 'project-1', + agents: [ + { name: 'scheduled', prompt: 'scheduled prompt', hasScheduler: true }, + { name: 'plain', prompt: 'plain prompt', hasScheduler: false }, + ], + client: { runAgent: async (request) => { requests.push(request); } }, + onStarted: (name) => started.push(name), + onFailed: () => {}, + onSettled: () => settled++, + }); + expect(requests.map(({ projectId, agentName, prompt, source }) => ({ projectId, agentName, prompt, source }))).toEqual([ + { projectId: 'project-1', agentName: 'scheduled', prompt: 'scheduled prompt', source: RunSource.MANUAL }, + { projectId: 'project-1', agentName: 'plain', prompt: 'plain prompt', source: RunSource.MANUAL }, + ]); + expect(started).toEqual(['scheduled', 'plain']); + expect(settled).toBe(2); + }); + + test('continues after a failed v2 run and settles every agent', async () => { + const failed = []; + const started = []; + let settled = 0; + await runProjectAgents({ + projectId: 'project-1', + agents: [{ name: 'bad', prompt: 'x' }, { name: 'good', prompt: 'y' }], + client: { runAgent: async (request) => { if (request.agentName === 'bad') throw new Error('boom'); } }, + onStarted: (name) => started.push(name), + onFailed: (name) => failed.push(name), + onSettled: () => settled++, + }); + expect(failed).toEqual(['bad']); + expect(started).toEqual(['good']); + expect(settled).toBe(2); + }); +}); diff --git a/src/lib/toolbar-actions.ts b/src/lib/toolbar-actions.ts new file mode 100644 index 0000000..16728ab --- /dev/null +++ b/src/lib/toolbar-actions.ts @@ -0,0 +1,752 @@ +import { + ApplyProjectRequest, + type ApplyProjectResponse, + ProjectSource, + ProjectRef, + RemoveProjectRequest, + type RemoveProjectResponse, + ListRunsRequest, + RunAgentRequest, + StartRunRequest, + GetRunRequest, + FollowRunLogsRequest, + RunSource, + RunStatus, + ProjectChangeAction, + StopRunRequest, + type RunLogChunk, + type RunSummary, + type StartRunResponse, + type GetRunResponse, + type ListRunsResponse, + ListSchedulersRequest, + type ListSchedulersResponse, + ListSchedulerRunsRequest, + type ListSchedulerRunsResponse, + SchedulerRunStatus, + StopSchedulerRunRequest, + GetSchedulerRequest, + type GetSchedulerResponse, + SetSchedulerEnabledRequest, + type SetSchedulerEnabledResponse, + ListSandboxesRequest, + type ListSandboxesResponse, + StopSandboxRequest, +} from '../gen/agentcompose/v2/agentcompose_pb'; +import { yamlToSpec } from './yaml'; +import { isSameProjectId } from './projects'; + +export interface ApplyProjectClient { + applyProject(request: ApplyProjectRequest, options?: { signal?: AbortSignal }): Promise; +} + +export interface RunAgentClient { + runAgent(request: RunAgentRequest): Promise; +} + +export interface TrackedRunClient { + startRun(request: StartRunRequest): Promise; + followRunLogs(request: FollowRunLogsRequest, options?: { signal?: AbortSignal }): AsyncIterable; + getRun(request: GetRunRequest, options?: { signal?: AbortSignal }): Promise; +} + +export interface RunYamlBatchOptions { + projectId: string; + agents: Array<{ name: string; prompt: string }>; + client: TrackedRunClient; + signal?: AbortSignal; + startOffset?: bigint; + isCurrent(): boolean; + onStarting(name: string): void; + onStarted(name: string, run: RunSummary): void; + onChunk(name: string, chunk: RunLogChunk): void; + onFinished(name: string, status: RunStatus, detail?: NonNullable): void; + onStartFailed(name: string, error: unknown): void; + onTrackingError(name: string, error: unknown): void; +} + +export async function runYamlBatch(options: RunYamlBatchOptions): Promise { + const active = () => !options.signal?.aborted && options.isCurrent(); + + for (const agent of options.agents) { + if (!active()) return; + options.onStarting(agent.name); + + let response: StartRunResponse; + try { + response = await options.client.startRun(new StartRunRequest({ + run: new RunAgentRequest({ + projectId: options.projectId, + agentName: agent.name, + prompt: agent.prompt, + source: RunSource.MANUAL, + }), + })); + } catch (error) { + if (!active()) return; + options.onStartFailed(agent.name, error); + continue; + } + + if (!active()) return; + if (!response.started || !response.run?.runId) { + const warnings = (response.warnings ?? []).map((warning) => warning.trim()).filter(Boolean); + const message = warnings.length > 0 + ? warnings.join('; ') + : response.started ? '运行未返回 Run ID' : '运行未启动'; + options.onStartFailed(agent.name, new Error(message)); + continue; + } + + const run = response.run; + options.onStarted(agent.name, run); + const getDetail = async () => { + const result = await options.client.getRun( + new GetRunRequest({ projectId: options.projectId, runId: run.runId }), + { signal: options.signal }, + ); + return result.run; + }; + let initialDetail: Awaited>; + try { + initialDetail = await getDetail(); + } catch { + initialDetail = undefined; + } + if (!active()) return; + if (initialDetail && ![RunStatus.PENDING, RunStatus.RUNNING, RunStatus.UNSPECIFIED].includes(initialDetail.summary?.status ?? RunStatus.UNSPECIFIED)) { + options.onFinished(agent.name, initialDetail.summary?.status ?? RunStatus.UNSPECIFIED, initialDetail); + continue; + } + let offset = options.startOffset ?? 0n; + let lastStatus = run.status; + try { + const request = new FollowRunLogsRequest({ + projectId: options.projectId, + runId: run.runId, + startOffset: offset, + follow: true, + }); + for await (const chunk of options.client.followRunLogs(request, { signal: options.signal })) { + if (!active()) return; + const hasNewData = chunk.offset > offset; + lastStatus = chunk.runStatus; + options.onChunk(agent.name, hasNewData || !chunk.data ? chunk : { ...chunk, data: '' } as RunLogChunk); + if (hasNewData) { + offset = chunk.offset; + } + if (chunk.isFinal) { + break; + } + } + } catch (error) { + if (active()) options.onTrackingError(agent.name, error); + return; + } + if (!active()) return; + let finalDetail: Awaited>; + try { + finalDetail = await getDetail(); + } catch (error) { + if (!active()) return; + if ([RunStatus.SUCCEEDED, RunStatus.FAILED, RunStatus.CANCELED].includes(lastStatus)) { + options.onFinished(agent.name, lastStatus); + continue; + } + options.onTrackingError(agent.name, error); + return; + } + if (!active() || !finalDetail) return; + if ([RunStatus.PENDING, RunStatus.RUNNING, RunStatus.UNSPECIFIED].includes(finalDetail.summary?.status ?? RunStatus.UNSPECIFIED)) return; + options.onFinished(agent.name, finalDetail.summary?.status ?? RunStatus.UNSPECIFIED, finalDetail); + } +} + +export interface StopProjectRunsClient { + listRuns(request: ListRunsRequest): Promise; + stopRun(request: StopRunRequest): Promise; +} + +export interface SoftPauseProjectClient extends StopProjectRunsClient { + listSchedulers(request: ListSchedulersRequest): Promise; + getScheduler(request: GetSchedulerRequest): Promise; + setSchedulerEnabled(request: SetSchedulerEnabledRequest): Promise; + listSchedulerRuns(request: ListSchedulerRunsRequest): Promise; + stopSchedulerRun(request: StopSchedulerRunRequest): Promise; + listSandboxes(request: ListSandboxesRequest): Promise; + stopSandbox(request: StopSandboxRequest): Promise; +} + +export type ProjectRuntimeActivityClient = Pick; + +export interface ProjectRuntimeActivity { + scheduler: boolean; + schedulerRun: boolean; + run: boolean; + sandbox: boolean; + active: boolean; +} + +export async function probeProjectRuntimeActivity(options: { + projectId: string; + client: ProjectRuntimeActivityClient; +}): Promise { + const { projectId, client } = options; + const [scheduler, schedulerRun, run, sandbox] = await Promise.all([ + hasEnabledScheduler(projectId, client), + hasActiveSchedulerRun(projectId, client), + hasActiveRun(projectId, client), + hasRunningSandbox(projectId, client), + ]); + return { scheduler, schedulerRun, run, sandbox, active: scheduler || schedulerRun || run || sandbox }; +} + +async function listProjectSchedulers( + projectId: string, + client: Pick, +): Promise> { + const schedulers: Array<{ projectId: string; agentName: string }> = []; + const seen = new Set(); + let cursor = ''; + while (true) { + const response = await client.listSchedulers(new ListSchedulersRequest({ limit: 500, cursor })); + schedulers.push(...(response.schedulers ?? []).filter((item) => ( + isSameProjectId(item.projectId, projectId) && !!item.agentName + )).map((item) => ({ projectId: item.projectId, agentName: item.agentName }))); + const next = response.nextCursor?.trim() ?? ''; + if (!next) return schedulers; + if (seen.has(next)) throw new Error(`ListSchedulers returned repeated cursor: ${next}`); + seen.add(next); + cursor = next; + } +} + +async function hasEnabledScheduler(projectId: string, client: ProjectRuntimeActivityClient): Promise { + const schedulers = await listProjectSchedulers(projectId, client); + const details = await Promise.all(schedulers.map((item) => client.getScheduler(new GetSchedulerRequest({ + project: new ProjectRef({ projectId: item.projectId }), + agentName: item.agentName, + })))); + return details.some((detail) => detail.scheduler?.enabled === true); +} + +async function hasActiveSchedulerRun(projectId: string, client: ProjectRuntimeActivityClient): Promise { + const seen = new Set(); + let cursor = ''; + while (true) { + const response = await client.listSchedulerRuns(new ListSchedulerRunsRequest({ + project: new ProjectRef({ projectId }), limit: 500, cursor, + })); + if ((response.runs ?? []).some((run) => run.status === SchedulerRunStatus.RUNNING)) return true; + const next = response.nextCursor?.trim() ?? ''; + if (!next) return false; + if (seen.has(next)) throw new Error(`ListSchedulerRuns returned repeated cursor: ${next}`); + seen.add(next); + cursor = next; + } +} + +async function hasActiveRun(projectId: string, client: ProjectRuntimeActivityClient): Promise { + for (const status of [RunStatus.PENDING, RunStatus.RUNNING]) { + let offset = 0; + while (true) { + const response = await client.listRuns(new ListRunsRequest({ projectId, status, limit: 500, offset })); + if ((response.runs ?? []).some((item) => !item.projectId || item.projectId === projectId)) return true; + if ((response.runs?.length ?? 0) < 500) break; + offset += response.runs.length; + } + } + return false; +} + +async function hasRunningSandbox(projectId: string, client: ProjectRuntimeActivityClient): Promise { + const seen = new Set(); + let cursor = ''; + while (true) { + const response = await client.listSandboxes(new ListSandboxesRequest({ limit: 500, cursor })); + if ((response.sandboxes ?? []).some((item) => ( + item.projectId === projectId && item.status.trim().toUpperCase() === 'RUNNING' + ))) return true; + const next = response.nextCursor?.trim() ?? ''; + if (!next) return false; + if (seen.has(next)) throw new Error(`ListSandboxes returned repeated cursor: ${next}`); + seen.add(next); + cursor = next; + } +} + +export interface PauseStageResult { + attempted: number; + succeeded: number; + failed: number; + errors: string[]; +} + +export interface SoftPauseProjectResult { + schedulers: PauseStageResult; + schedulerRuns: PauseStageResult; + runs: PauseStageResult; + sandboxes: PauseStageResult; + failed: number; +} + +export interface DeleteProjectClient { + removeProject(request: RemoveProjectRequest): Promise; +} + +interface ProjectNameEntry { + summary: { + projectId: string; + name: string; + sourcePath?: string; + specHash?: string; + }; +} + +export function createPreviewGeneration(onInvalidate: () => void) { + let current = 0; + return { + begin() { onInvalidate(); return ++current; }, + invalidate() { ++current; onInvalidate(); }, + isCurrent(token: number) { return token === current; }, + }; +} + +export interface PreparedProjectPreview { + currentProjectId: string; + editorContent: string; + prepared: TPrepared; + response: ApplyProjectResponse; + apply: (signal?: AbortSignal) => ReturnType; +} + +export async function prepareProjectPreview(options: { + mode: 'save' | 'run'; + currentProjectId: string; + editorContent: string; + projects: ProjectNameEntry[]; + fallbackSpecHash: string; + prepare: (editorContent: string) => Promise; + preflight?: (prepared: TPrepared) => Promise; + client: ApplyProjectClient; + isCurrent: (projectId: string) => boolean; +}): Promise | undefined> { + const snapshotProjects = options.projects.map((project) => ({ summary: { ...project.summary } })); + const snapshotProject = snapshotProjects.find((project) => isSameProjectId(project.summary.projectId, options.currentProjectId)); + const prepared = await options.prepare(options.editorContent); + if (!options.isCurrent(options.currentProjectId)) return undefined; + if (options.preflight) await options.preflight(prepared); + if (!options.isCurrent(options.currentProjectId)) return undefined; + const preview = await previewProject(options.editorContent, options.client, { + currentProjectId: options.currentProjectId, + projects: snapshotProjects, + preparedYaml: prepared.yamlText, + expectedSpecHash: snapshotProject?.summary.specHash || options.fallbackSpecHash, + }); + if (!options.isCurrent(options.currentProjectId)) return undefined; + return { currentProjectId: options.currentProjectId, editorContent: options.editorContent, prepared, ...preview }; +} + +export async function consumePendingApply(slot: { + take: () => { apply: (signal?: AbortSignal) => Promise } | undefined; + restore: (pending: { apply: (signal?: AbortSignal) => Promise }) => void; +}, signal?: AbortSignal): Promise { + const pending = slot.take(); + if (!pending) return undefined; + try { + return await pending.apply(signal); + } catch (error) { + slot.restore(pending); + throw error; + } +} + +export interface SaveProjectOptions { + currentProjectId: string; + projects: ProjectNameEntry[]; + preparedYaml?: string; + expectedSpecHash?: string; +} + +function prepareApplyRequest(editorContent: string, options?: SaveProjectOptions) { + const sourceYaml = options?.preparedYaml ?? editorContent; + const { spec, error } = yamlToSpec(sourceYaml); + if (error) throw new Error(`YAML 解析错误: ${error}`); + + const projectName = spec.name.trim(); + const duplicate = options?.projects.find((project) => ( + !isSameProjectId(project.summary.projectId, options.currentProjectId) && + project.summary.name.trim() === projectName + )); + if (projectName && duplicate) throw new Error(`智能体应用名称 "${projectName}" 已存在,请修改名称`); + + const currentProject = options?.projects.find((project) => isSameProjectId(project.summary.projectId, options.currentProjectId)); + return { + spec, + source: new ProjectSource({ composePath: currentProject?.summary.sourcePath?.trim() || 'agent-compose.yml' }), + expectedSpecHash: options?.expectedSpecHash || '', + }; +} + +export async function previewProject(editorContent: string, client: ApplyProjectClient, options?: SaveProjectOptions) { + const prepared = prepareApplyRequest(editorContent, options); + const response = await client.applyProject(new ApplyProjectRequest({ + ...prepared, + expectedSpecHash: '', + dryRun: true, + })); + const savedSpecHash = prepared.expectedSpecHash.trim(); + const previewSpecHash = response.revision?.specHash.trim() || ''; + if (savedSpecHash && savedSpecHash === previewSpecHash) { + for (const change of response.changes) { + if (change.action === ProjectChangeAction.CREATED) { + change.action = ProjectChangeAction.UNCHANGED; + } + } + response.unchanged = response.changes.every( + (change) => change.action === ProjectChangeAction.UNCHANGED, + ); + } + const confirmed = { + ...prepared, + expectedSpecHash: response.revision?.specHash || '', + }; + return { + response, + apply: (signal?: AbortSignal) => saveProject(editorContent, client, options, confirmed, signal), + }; +} + +export function getAppliedProjectId( + response: Pick, + currentProjectId: string, +): string { + return response.project?.summary?.projectId || currentProjectId; +} + +export async function saveProject( + editorContent: string, + client: ApplyProjectClient, + options?: SaveProjectOptions, + preparedRequest = prepareApplyRequest(editorContent, options), + signal?: AbortSignal, +) { + const { spec } = preparedRequest; + const request = new ApplyProjectRequest({ + ...preparedRequest, + dryRun: false, + }); + const response = signal + ? await client.applyProject(request, { signal }) + : await client.applyProject(request); + if (!response.applied && !response.unchanged) throw new Error('保存未生效'); + + const appliedProjectId = getAppliedProjectId(response, ''); + const currentProjectId = options?.currentProjectId || ''; + const supersededProjectId = currentProjectId.startsWith('sha256:') && + appliedProjectId !== currentProjectId && + isSameProjectId(currentProjectId, appliedProjectId) + ? currentProjectId + : ''; + + const agents = spec.agents + .filter((agent) => !!agent.name) + .map((agent) => ({ + name: agent.name, + prompt: agent.systemPrompt || '', + hasScheduler: !!agent.scheduler, + })); + + return { + response, + agentNames: agents.map((agent) => agent.name), + agents, + supersededProjectId, + }; +} + +export async function deleteProject( + projectId: string, + client: DeleteProjectClient, + options: { stopRunningSessions?: boolean } = {}, +) { + return client.removeProject(new RemoveProjectRequest({ + project: new ProjectRef({ projectId }), + removeHistory: false, + stopRunningSandboxes: options.stopRunningSessions ?? true, + })); +} + +export async function stopProjectRuns(options: { + projectId: string; + client: StopProjectRunsClient; +}) { + const pageSize = 200; + const activeRuns: { runId: string }[] = []; + + for (const status of [RunStatus.PENDING, RunStatus.RUNNING]) { + let offset = 0; + while (true) { + const response = await options.client.listRuns(new ListRunsRequest({ + projectId: options.projectId, + status, + offset, + limit: pageSize, + })); + const runs = response.runs || []; + activeRuns.push(...runs.filter((run) => !!run.runId)); + if (runs.length < pageSize) break; + offset += runs.length; + } + } + + let stopped = 0; + let failed = 0; + const errors: string[] = []; + for (const run of activeRuns) { + try { + await options.client.stopRun(new StopRunRequest({ + runId: run.runId, + reason: 'stopped from web console', + })); + stopped++; + } catch (error) { + failed++; + errors.push(`Failed to stop run ${run.runId}: ${pauseError(error)}`); + } + } + + return { attempted: activeRuns.length, stopped, failed, errors }; +} + +function pauseError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function pauseSchedulers(projectId: string, client: SoftPauseProjectClient): Promise { + const schedulers = await listProjectSchedulers(projectId, client); + + const result: PauseStageResult = { attempted: 0, succeeded: 0, failed: 0, errors: [] }; + for (const scheduler of schedulers) { + let attempted = false; + try { + const detail = await client.getScheduler(new GetSchedulerRequest({ + project: new ProjectRef({ projectId: scheduler.projectId }), + agentName: scheduler.agentName, + })); + if (detail.scheduler?.enabled !== true) continue; + result.attempted++; + attempted = true; + const response = await client.setSchedulerEnabled(new SetSchedulerEnabledRequest({ + project: new ProjectRef({ projectId: scheduler.projectId }), + agentName: scheduler.agentName, + enabled: false, + })); + if (!response.scheduler || response.scheduler.enabled) { + throw new Error(`Scheduler ${scheduler.agentName} 未确认已禁用`); + } + result.succeeded++; + } catch (error) { + if (!attempted) result.attempted++; + result.failed++; + result.errors.push(pauseError(error)); + } + } + return result; +} + +async function pauseSchedulerRuns(projectId: string, client: SoftPauseProjectClient): Promise { + const activeRuns: Array<{ runId: string }> = []; + const seenCursors = new Set(); + let cursor = ''; + while (true) { + const response = await client.listSchedulerRuns(new ListSchedulerRunsRequest({ + project: new ProjectRef({ projectId }), limit: 500, cursor, + })); + activeRuns.push(...(response.runs ?? []).filter((run) => ( + run.status === SchedulerRunStatus.RUNNING && !!run.runId + )).map((run) => ({ runId: run.runId }))); + const nextCursor = response.nextCursor?.trim() ?? ''; + if (!nextCursor) break; + if (seenCursors.has(nextCursor)) throw new Error(`ListSchedulerRuns returned repeated cursor: ${nextCursor}`); + seenCursors.add(nextCursor); + cursor = nextCursor; + } + + const result: PauseStageResult = { attempted: activeRuns.length, succeeded: 0, failed: 0, errors: [] }; + for (const run of activeRuns) { + try { + await client.stopSchedulerRun(new StopSchedulerRunRequest({ + project: new ProjectRef({ projectId }), runId: run.runId, reason: 'project paused from web console', + })); + result.succeeded++; + } catch (error) { + result.failed++; + result.errors.push(`Failed to stop Scheduler Run ${run.runId}: ${pauseError(error)}`); + } + } + return result; +} + +// sandboxBelongsToProject matches a sandbox to a project. Scheduler/webhook +// triggered loader runs create sandboxes whose top-level `projectId` is empty +// (the project id only appears in the `project` tag), so a strict equality +// check on `projectId` would skip them. Fall back to the `project` tag so the +// soft-pause flow still stops those sandboxes. +function sandboxBelongsToProject( + sandbox: { projectId?: string; tags?: Array<{ name: string; value: string }> }, + projectId: string, +): boolean { + if (isSameProjectId(sandbox.projectId ?? '', projectId)) { + return true; + } + const projectTag = (sandbox.tags ?? []).find((tag) => tag.name === 'project'); + return !!projectTag && isSameProjectId(projectTag.value, projectId); +} + +async function pauseSandboxes(projectId: string, client: SoftPauseProjectClient): Promise { + const sandboxes: Array<{ sandboxId: string }> = []; + const seenCursors = new Set(); + let cursor = ''; + while (true) { + const response = await client.listSandboxes(new ListSandboxesRequest({ limit: 500, cursor })); + sandboxes.push(...(response.sandboxes ?? []).filter((sandbox) => ( + sandboxBelongsToProject(sandbox, projectId) && + sandbox.status.trim().toUpperCase() === 'RUNNING' && + !!sandbox.sandboxId + ))); + const nextCursor = response.nextCursor?.trim() ?? ''; + if (!nextCursor) break; + if (seenCursors.has(nextCursor)) throw new Error(`ListSandboxes returned repeated cursor: ${nextCursor}`); + seenCursors.add(nextCursor); + cursor = nextCursor; + } + + const result: PauseStageResult = { attempted: sandboxes.length, succeeded: 0, failed: 0, errors: [] }; + for (const sandbox of sandboxes) { + try { + await client.stopSandbox(new StopSandboxRequest({ sandboxId: sandbox.sandboxId })); + result.succeeded++; + } catch (error) { + result.failed++; + result.errors.push(pauseError(error)); + } + } + return result; +} + +async function runPauseStage(stage: () => Promise): Promise { + try { + return await stage(); + } catch (error) { + return { attempted: 0, succeeded: 0, failed: 1, errors: [pauseError(error)] }; + } +} + +const pauseConvergenceRounds = 6; +const requiredIdlePauseRounds = 2; + +function mergePauseStage(target: PauseStageResult, current: PauseStageResult): void { + target.attempted += current.attempted; + target.succeeded += current.succeeded; + target.failed += current.failed; + target.errors.push(...current.errors); +} + +export async function softPauseProject(options: { + projectId: string; + client: SoftPauseProjectClient; +}): Promise { + const schedulers = await runPauseStage(() => pauseSchedulers(options.projectId, options.client)); + const schedulerRuns: PauseStageResult = { attempted: 0, succeeded: 0, failed: 0, errors: [] }; + const runs: PauseStageResult = { attempted: 0, succeeded: 0, failed: 0, errors: [] }; + const sandboxes: PauseStageResult = { attempted: 0, succeeded: 0, failed: 0, errors: [] }; + let idleRounds = 0; + let stageFailed = false; + for (let round = 0; round < pauseConvergenceRounds; round++) { + const currentSchedulerRuns = await runPauseStage(() => pauseSchedulerRuns(options.projectId, options.client)); + const currentRuns = await runPauseStage(async () => { + const result = await stopProjectRuns(options); + return { attempted: result.attempted, succeeded: result.stopped, failed: result.failed, errors: result.errors }; + }); + const currentSandboxes = await runPauseStage(() => pauseSandboxes(options.projectId, options.client)); + mergePauseStage(schedulerRuns, currentSchedulerRuns); + mergePauseStage(runs, currentRuns); + mergePauseStage(sandboxes, currentSandboxes); + + if (currentSchedulerRuns.failed > 0 || currentRuns.failed > 0 || currentSandboxes.failed > 0) { + stageFailed = true; + break; + } + + if (currentSchedulerRuns.attempted === 0 && currentRuns.attempted === 0 && currentSandboxes.attempted === 0) { + idleRounds++; + if (idleRounds >= requiredIdlePauseRounds) break; + } else { + idleRounds = 0; + } + } + if (!stageFailed && idleRounds < requiredIdlePauseRounds) { + runs.failed++; + runs.errors.push(`项目暂停在 ${pauseConvergenceRounds} 轮检查后仍未收敛`); + } + return { + schedulers, + schedulerRuns, + runs, + sandboxes, + failed: schedulers.failed + schedulerRuns.failed + runs.failed + sandboxes.failed, + }; +} + +export async function runProjectAgents(options: { + projectId: string; + agents: Array<{ name: string; prompt: string }>; + client: RunAgentClient; + onStarted(name: string): void; + onFailed(name: string, error: unknown): void; + onSettled(): void; + shouldContinue?: () => boolean; +}) { + for (const agent of options.agents) { + if (options.shouldContinue && !options.shouldContinue()) return; + try { + await options.client.runAgent(new RunAgentRequest({ + projectId: options.projectId, + agentName: agent.name, + prompt: agent.prompt, + source: RunSource.MANUAL, + })); + options.onStarted(agent.name); + } catch (error) { + options.onFailed(agent.name, error); + } finally { + options.onSettled(); + } + } +} + +export async function runProjectAgentsIfCurrent(options: { + projectId: string; + agents: Array<{ name: string; prompt: string }>; + client: RunAgentClient; + isCurrent: () => boolean; + onNavigate(): void; + onStarted(name: string): void; + onFailed(name: string, error: unknown): void; + onSettled(): void; +}) { + if (!options.isCurrent()) return; + options.onNavigate(); + if (!options.isCurrent()) return; + await runProjectAgents({ + projectId: options.projectId, + agents: options.agents, + client: options.client, + shouldContinue: options.isCurrent, + onStarted: (name) => { if (options.isCurrent()) options.onStarted(name); }, + onFailed: (name, error) => { if (options.isCurrent()) options.onFailed(name, error); }, + onSettled: () => { if (options.isCurrent()) options.onSettled(); }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..b31fa26 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,29 @@ +export interface ProjectSource { + composePath: string; + projectDir: string; +} + +export interface ProjectEntry { + summary: { + projectId: string; + name: string; + sourcePath: string; + currentRevision: bigint; + specHash: string; + agentCount: number; + schedulerCount: number; + runningRunCount: number; + latestRunId: string; + createdAt: string; + updatedAt: string; + }; + source: ProjectSource; + yamlContent: string; + dirty: boolean; +} + +export interface ToastMessage { + id: number; + message: string; + level: 'info' | 'error' | 'success'; +} diff --git a/src/lib/v2-audit.test.js b/src/lib/v2-audit.test.js new file mode 100644 index 0000000..d71e377 --- /dev/null +++ b/src/lib/v2-audit.test.js @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const report = readFileSync(new URL('../../test/fixtures/v2-capability-matrix.md', import.meta.url), 'utf8'); +const expectedHeader = '| 能力域 | 用户任务 | CLI 用户价值 | v2 契约 | 当前 Web | 是否迫使使用 CLI | 决策 | 优先级 | 验收标准 | 证据 |'; +const rows = report.split('\n') + .filter(line => line.startsWith('| ') && line !== expectedHeader && !line.startsWith('|---')) + .map(line => line.split('|').slice(1, -1).map(cell => cell.trim())); + +describe('v2 capability audit', () => { + test('covers every v2 service and every decision class', () => { + for (const service of ['Project', 'Run', 'Exec', 'Image', 'Cache', 'Volume', 'Sandbox']) { + expect(report).toContain(`| ${service} |`); + } + for (const decision of ['保留', '补齐', '合并', '重构', '移除', '禁用', '后端阻塞', 'CLI 专属']) { + expect(rows.some(row => row[6] === decision)).toBe(true); + } + }); + + test('records v1-only product areas explicitly', () => { + for (const area of ['Dashboard', 'Session', 'Scheduler', 'Loader Run', '全局环境变量', '能力目录']) { + expect(report).toContain(area); + } + }); + + test('keeps the matrix structurally reviewable', () => { + expect(report).toContain(expectedHeader); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row).toHaveLength(10); + expect(row.every(cell => cell.length > 0)).toBe(true); + } + expect(report).not.toContain('视情况而定'); + }); + + test('requires the exact v2 unavailable message for disabled capabilities', () => { + const unavailableAreas = ['Session', 'Loader Run']; + const unavailableRows = rows.filter(row => unavailableAreas.includes(row[0]) || ['禁用', '后端阻塞'].includes(row[6])); + expect(unavailableRows.length).toBeGreaterThan(0); + for (const row of unavailableRows) { + expect(row[8]).toContain('当前 v2 API 未提供此能力'); + } + }); +}); diff --git a/src/lib/v2-capabilities.ts b/src/lib/v2-capabilities.ts new file mode 100644 index 0000000..5571cdb --- /dev/null +++ b/src/lib/v2-capabilities.ts @@ -0,0 +1,8 @@ +export type V2CapabilityId = + | 'session-lifecycle' + | 'loader-runs'; + +export const V2_CAPABILITIES: Record = { + 'session-lifecycle': { available: false, reason: 'v2 未提供独立 Session 清单与停止接口;交互环境由 Sandbox 承载,文件通过 Exec、恢复通过 Run 复用、Jupyter 通过 HTTP 代理提供。' }, + 'loader-runs': { available: false, reason: 'v2 未提供独立 Loader Run 服务;计划执行由 Scheduler、运行进度由 Run Event 承载。' }, +}; diff --git a/src/lib/v2-generated-api.test.ts b/src/lib/v2-generated-api.test.ts new file mode 100644 index 0000000..164fe02 --- /dev/null +++ b/src/lib/v2-generated-api.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { DashboardService, RunService, SandboxService, SettingsService, CapabilityService } from '../gen/agentcompose/v2/agentcompose_connect'; + +describe('generated V2 API surface', () => { + it('contains the direct APIs used by the frontend', () => { + expect(Object.keys(SandboxService.methods)).toEqual(expect.arrayContaining([ + 'getSandbox', 'listSandboxes', 'stopSandbox', 'resumeSandbox', + 'listSandboxHistory', 'watchSandbox', + ])); + expect(Object.keys(RunService.methods)).toEqual(expect.arrayContaining(['listRunEvents', 'listSandboxRunEvents'])); + expect(Object.keys(DashboardService.methods)).toContain('watchDashboardOverview'); + expect(Object.keys(SettingsService.methods)).toEqual(expect.arrayContaining(['getGlobalEnv', 'updateGlobalEnv', 'listWorkspacePresets'])); + expect(Object.keys(CapabilityService.methods)).toEqual(expect.arrayContaining(['getCapabilityStatus', 'listCapabilitySets', 'getCapabilityCatalog'])); + }); +}); diff --git a/src/lib/v2-only.test.js b/src/lib/v2-only.test.js new file mode 100644 index 0000000..3305155 --- /dev/null +++ b/src/lib/v2-only.test.js @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +function files(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? files(path) : [path]; + }); +} + +describe('v2-only application boundary', () => { + test('rpc exports all v2 services and no v1 clients', () => { + const source = readFileSync('src/lib/rpc.ts', 'utf8'); + const expected = [ + 'projectService', 'runService', 'execService', 'imageService', 'cacheService', + 'volumeService', 'sandboxService', 'dashboardService', 'settingsService', + 'capabilityService', + ]; + for (const name of expected) { + expect(source).toContain(`export const ${name}`); + } + const registered = [...source.matchAll(/export const (\w+)\s*=\s*createClient\(/g)].map((match) => match[1]).sort(); + expect(registered).toEqual([...expected].sort()); + expect(source).not.toMatch(/agentcompose\/v1|health\/v1|SessionService|LoaderService|ConfigService|HealthService/); + }); + + test('business source never references agentcompose or health v1', () => { + const offenders = files('src') + .filter((path) => !path.includes('/gen/')) + .filter((path) => !path.endsWith('.test.js') && !path.endsWith('.test.ts')) + .filter((path) => /\.(svelte|ts|js)$/.test(path)) + .filter((path) => /(?:agentcompose|health)\/v1/.test(readFileSync(path, 'utf8'))); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/lib/volumes.ts b/src/lib/volumes.ts new file mode 100644 index 0000000..0828598 --- /dev/null +++ b/src/lib/volumes.ts @@ -0,0 +1,8 @@ +import type { Volume } from '../gen/agentcompose/v2/agentcompose_pb'; + +export function volumeName(volume: Volume): string { return volume.name || '后端未提供'; } +export function formatVolumeTime(value: string): string { + if (!value) return '后端未提供'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN'); +} diff --git a/src/lib/yaml-run-batch-store.test.js b/src/lib/yaml-run-batch-store.test.js new file mode 100644 index 0000000..7dc104e --- /dev/null +++ b/src/lib/yaml-run-batch-store.test.js @@ -0,0 +1,164 @@ +import { describe, expect, test } from 'bun:test'; +import { createYamlRunBatch, replaceBatchAgent } from './yaml-run-batch'; +import { createYamlRunBatchStore, resolveBrowserStorage } from './yaml-run-batch.svelte'; + +class MapStorage { + values = new Map(); + + getItem(key) { + return this.values.get(key) ?? null; + } + + setItem(key, value) { + this.values.set(key, value); + } + + removeItem(key) { + this.values.delete(key); + } +} + +describe('yaml run batch store', () => { + test('a new batch overwrites the old batch and stale updates are rejected', () => { + const storage = new MapStorage(); + const batches = createYamlRunBatchStore(storage); + const old = batches.create('p1', [{ name: 'a', prompt: '' }], 'old'); + const current = batches.create('p1', [{ name: 'b', prompt: '' }], 'new'); + + expect(batches.update('p1', old.batchId, (value) => value)).toBe(false); + expect(batches.current('p1')?.batchId).toBe(current.batchId); + }); + + test('refresh marks agents without run ids as interrupted without restarting them', () => { + const storage = new MapStorage(); + const batches = createYamlRunBatchStore(storage); + const batch = batches.create('p1', [{ name: 'a', prompt: '' }], 'batch'); + batches.update('p1', batch.batchId, (value) => + replaceBatchAgent(value, 'a', { status: 'starting' }) + ); + + batches.reconcileInterrupted('p1'); + + expect(batches.current('p1')?.agents[0].status).toBe('start-interrupted'); + }); + + test('restore reconciles interrupted agents once but create does not interrupt a fresh batch', () => { + const storage = new MapStorage(); + const seed = createYamlRunBatch('p1', [{ name: 'a', prompt: 'prompt' }], '2026-01-01T00:00:00Z', 'restored'); + storage.setItem('agent-compose:yaml-run:p1', JSON.stringify(seed)); + const batches = createYamlRunBatchStore(storage); + expect(batches.restore('p1')?.agents[0].status).toBe('start-interrupted'); + expect(batches.restore('p1')?.agents[0].status).toBe('start-interrupted'); + const fresh = batches.create('p1', [{ name: 'b', prompt: 'fresh' }], 'fresh'); + expect(fresh.agents[0].status).toBe('waiting'); + expect(batches.current('p1')?.agents[0].status).toBe('waiting'); + }); + + test('an older tab cannot overwrite a newer stored batch and storage events use deterministic ordering', () => { + const storage = new MapStorage(); + const oldTab = createYamlRunBatchStore(storage); + const old = oldTab.create('p1', [{ name: 'old', prompt: '' }], 'old'); + storage.setItem('agent-compose:yaml-run:p1', JSON.stringify(createYamlRunBatch('p1', [{ name: 'new', prompt: '' }], '2099-01-01T00:00:00Z', 'new'))); + expect(oldTab.update('p1', old.batchId, value => replaceBatchAgent(value, 'old', { status: 'running' }))).toBe(false); + expect(oldTab.current('p1')?.batchId).toBe('new'); + + const older = createYamlRunBatch('p1', [], '2000-01-01T00:00:00Z', 'z'); + expect(oldTab.applyStorageEvent('agent-compose:yaml-run:p1', JSON.stringify(older))).toBe(false); + expect(oldTab.current('p1')?.batchId).toBe('new'); + }); + + test('lazily restores normalized projects and tracks mutations by generation', () => { + const storage = new MapStorage(); + const writer = createYamlRunBatchStore(storage); + writer.create('sha256:p1', [{ name: 'a', prompt: 'A' }], 'batch'); + + const batches = createYamlRunBatchStore(storage); + expect(batches.generation('p1')).toBe(0); + expect(batches.current(' p1 ')?.batchId).toBe('batch'); + expect(batches.generation('sha256:p1')).toBe(0); + + const batch = batches.create('p1', [{ name: 'b', prompt: 'B' }], 'replacement'); + expect(batches.generation('sha256:p1')).toBe(1); + expect(batches.update('sha256:p1', batch.batchId, (value) => value)).toBe(true); + expect(batches.generation('p1')).toBe(2); + + batches.clearProject(' sha256:p1 '); + expect(batches.current('p1')).toBeUndefined(); + expect(batches.generation('p1')).toBe(3); + expect(storage.getItem('agent-compose:yaml-run:p1')).toBeNull(); + }); + + test('publishes the validated event value instead of rereading current storage', () => { + const storage = new MapStorage(); + const batches = createYamlRunBatchStore(storage); + batches.create('p1', [{ name: 'a', prompt: '' }], 'current'); + const incoming = createYamlRunBatch('p1', [{ name: 'b', prompt: '' }], 'now', 'incoming'); + + batches.applyStorageEvent('agent-compose:yaml-run:p1', JSON.stringify(incoming)); + + expect(batches.current('p1')?.batchId).toBe('incoming'); + expect(JSON.parse(storage.getItem('agent-compose:yaml-run:p1') ?? '{}').batchId).toBe( + 'current' + ); + }); + + test('rejects mismatched snapshots and non-canonical event keys', () => { + const storage = new MapStorage(); + const batches = createYamlRunBatchStore(storage); + batches.create('p1', [{ name: 'a', prompt: '' }], 'current'); + const wrongProject = createYamlRunBatch('p2', [], 'now', 'wrong'); + + for (const [key, value] of [ + ['agent-compose:yaml-run:p1', JSON.stringify(wrongProject)], + ['agent-compose:yaml-run:', null], + ['agent-compose:yaml-run:sha256:p1', null], + ['agent-compose:yaml-run: p1', null] + ]) { + batches.applyStorageEvent(key, value); + } + + expect(batches.current('p1')?.batchId).toBe('current'); + }); + + test('requires the payload project id to exactly match the canonical event key', () => { + const storage = new MapStorage(); + const batches = createYamlRunBatchStore(storage); + batches.create('p1', [{ name: 'a', prompt: '' }], 'current'); + const generation = batches.generation('p1'); + const canonical = createYamlRunBatch('p1', [], 'now', 'incoming'); + + for (const projectId of ['sha256:p1', ' p1 ']) { + batches.applyStorageEvent( + 'agent-compose:yaml-run:p1', + JSON.stringify({ ...canonical, projectId }) + ); + } + + expect(batches.current('p1')?.batchId).toBe('current'); + expect(batches.generation('p1')).toBe(generation); + expect( + batches.applyStorageEvent('agent-compose:yaml-run:p1', JSON.stringify(canonical)) + ).toBe(true); + expect(batches.current('p1')?.batchId).toBe('incoming'); + expect(batches.generation('p1')).toBe(generation + 1); + }); + + test('a canonical storage removal event clears the project', () => { + const storage = new MapStorage(); + const batches = createYamlRunBatchStore(storage); + batches.create('p1', [{ name: 'a', prompt: '' }], 'current'); + + batches.applyStorageEvent('agent-compose:yaml-run:p1', null); + + expect(batches.current('p1')).toBeUndefined(); + }); + + test('falls back when obtaining browser storage throws', () => { + const storage = resolveBrowserStorage(() => { + throw new DOMException('denied', 'SecurityError'); + }); + + expect(storage.getItem('key')).toBeNull(); + expect(() => storage.setItem('key', 'value')).not.toThrow(); + }); +}); diff --git a/src/lib/yaml-run-batch.svelte.ts b/src/lib/yaml-run-batch.svelte.ts new file mode 100644 index 0000000..84403cd --- /dev/null +++ b/src/lib/yaml-run-batch.svelte.ts @@ -0,0 +1,169 @@ +import { + batchStorageKey, + createYamlRunBatch, + readYamlRunBatch, + replaceBatchAgent, + writeYamlRunBatch, + type YamlRunBatch +} from './yaml-run-batch'; + +type BatchStorage = Pick; +type BatchUpdater = (batch: YamlRunBatch) => YamlRunBatch; + +const normalizeProjectId = (projectId: string) => projectId.trim().replace(/^sha256:/, ''); +const storageKeyPrefix = 'agent-compose:yaml-run:'; +const fallbackStorage: BatchStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined +}; + +export function resolveBrowserStorage(getStorage: () => BatchStorage): BatchStorage { + try { + return getStorage(); + } catch { + return fallbackStorage; + } +} + +export function createYamlRunBatchStore(storage: BatchStorage) { + let batches = $state>({}); + let loaded = new Set(); + let generations = $state>({}); + let restored = new Set(); + + const compareBatch = (left: YamlRunBatch, right: YamlRunBatch) => { + const byStartedAt = left.startedAt.localeCompare(right.startedAt); + return byStartedAt || left.batchId.localeCompare(right.batchId); + }; + + const publish = (projectId: string, batch: YamlRunBatch | undefined) => { + const next = { ...batches }; + if (batch) next[projectId] = batch; + else delete next[projectId]; + batches = next; + generations = { ...generations, [projectId]: (generations[projectId] ?? 0) + 1 }; + }; + + const current = (projectId: string): YamlRunBatch | undefined => { + const normalized = normalizeProjectId(projectId); + if (!loaded.has(normalized)) { + loaded.add(normalized); + const stored = readYamlRunBatch(storage, normalized); + if (stored) batches = { ...batches, [normalized]: stored }; + } + return batches[normalized]; + }; + + const update = (projectId: string, batchId: string, updater: BatchUpdater): boolean => { + const normalized = normalizeProjectId(projectId); + const batch = current(normalized); + if (!batch || batch.projectId !== normalized || batch.batchId !== batchId) return false; + const stored = readYamlRunBatch(storage, normalized); + if (stored && stored.batchId !== batchId) { + publish(normalized, stored); + return false; + } + + const updated = updater(batch); + if (updated.projectId !== normalized || updated.batchId !== batchId) return false; + writeYamlRunBatch(storage, updated); + publish(normalized, updated); + return true; + }; + + return { + current, + peek(projectId: string): YamlRunBatch | undefined { + return batches[normalizeProjectId(projectId)]; + }, + + restore(projectId: string): YamlRunBatch | undefined { + const normalized = normalizeProjectId(projectId); + const batch = current(normalized); + if (!restored.has(normalized)) { + restored.add(normalized); + if (batch) this.reconcileInterrupted(normalized); + } + return current(normalized); + }, + + create(projectId: string, agents: Array<{ name: string; prompt: string }>, batchId?: string) { + const normalized = normalizeProjectId(projectId); + const batch = createYamlRunBatch(normalized, agents, new Date().toISOString(), batchId); + loaded.add(normalized); + writeYamlRunBatch(storage, batch); + publish(normalized, batch); + return batch; + }, + + update, + + reconcileInterrupted(projectId: string): boolean { + const normalized = normalizeProjectId(projectId); + const batch = current(normalized); + if (!batch) return false; + const interrupted = batch.agents.filter( + (agent) => !agent.runId && ['waiting', 'starting', 'pending', 'running'].includes(agent.status) + ); + if (interrupted.length === 0) return false; + + return update(normalized, batch.batchId, (value) => + interrupted.reduce( + (next, agent) => replaceBatchAgent(next, agent.agentName, { status: 'start-interrupted' }), + value + ) + ); + }, + + clearProject(projectId: string): void { + const normalized = normalizeProjectId(projectId); + loaded.add(normalized); + try { + storage.removeItem(batchStorageKey(normalized)); + } catch { + // Persistence is best-effort (for example, storage may be unavailable). + } + publish(normalized, undefined); + }, + + generation(projectId: string): number { + return generations[normalizeProjectId(projectId)] ?? 0; + }, + + applyStorageEvent(key: string, newValue: string | null): boolean { + if (!key.startsWith(storageKeyPrefix)) return false; + const projectId = key.slice(storageKeyPrefix.length); + if (!projectId || projectId !== normalizeProjectId(projectId) || key !== batchStorageKey(projectId)) { + return false; + } + + loaded.add(projectId); + if (newValue === null) { + publish(projectId, undefined); + return true; + } + + const incoming = readYamlRunBatch({ getItem: () => newValue }, projectId); + if (!incoming || incoming.projectId !== projectId) return false; + const existing = current(projectId); + if (existing && incoming.batchId !== existing.batchId && compareBatch(incoming, existing) <= 0) return false; + publish(projectId, incoming); + return true; + } + }; +} + +const hasBrowserWindow = typeof window !== 'undefined'; +const browserStorage = hasBrowserWindow + ? resolveBrowserStorage(() => window.localStorage) + : fallbackStorage; + +export const yamlRunBatches = createYamlRunBatchStore(browserStorage); + +if (hasBrowserWindow) { + window.addEventListener('storage', (event) => { + if ((event.storageArea && event.storageArea !== browserStorage) || event.key === null) return; + yamlRunBatches.applyStorageEvent(event.key, event.newValue); + }); +} diff --git a/src/lib/yaml-run-batch.test.js b/src/lib/yaml-run-batch.test.js new file mode 100644 index 0000000..47dddac --- /dev/null +++ b/src/lib/yaml-run-batch.test.js @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test'; +import { + batchStats, + createYamlRunBatch, + readYamlRunBatch, + replaceBatchAgent, + writeYamlRunBatch +} from './yaml-run-batch'; + +class MapStorage { + values = new Map(); + + getItem(key) { + return this.values.get(key) ?? null; + } + + setItem(key, value) { + this.values.set(key, value); + } +} + +describe('yaml run batch', () => { + test('creates a batch ID when crypto.randomUUID is unavailable', () => { + const cryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { getRandomValues: crypto.getRandomValues.bind(crypto) } + }); + try { + const batch = createYamlRunBatch('p1', [{ name: 'a', prompt: '' }], 'now'); + expect(batch.batchId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + } finally { + if (cryptoDescriptor) Object.defineProperty(globalThis, 'crypto', cryptoDescriptor); + else delete globalThis.crypto; + } + }); + + test('creates agents in YML order and derives running/completed state', () => { + const batch = createYamlRunBatch( + 'p1', + [ + { name: 'a', prompt: 'A' }, + { name: 'b', prompt: 'B' } + ], + '2026-07-15T10:00:00Z', + 'batch-1' + ); + expect(batch.agents.map((agent) => agent.agentName)).toEqual(['a', 'b']); + expect(batchStats(batch)).toMatchObject({ total: 2, completed: 0, active: true }); + const done = replaceBatchAgent( + replaceBatchAgent(batch, 'a', { status: 'succeeded' }), + 'b', + { status: 'start-failed', startError: 'offline' } + ); + expect(batchStats(done)).toMatchObject({ total: 2, completed: 2, active: false }); + }); + + test('round trips only valid project-scoped version 1 snapshots', () => { + const storage = new MapStorage(); + const batch = createYamlRunBatch('p1', [{ name: 'a', prompt: '' }], 'now', 'batch-1'); + writeYamlRunBatch(storage, batch); + expect(readYamlRunBatch(storage, 'p1')).toEqual(batch); + expect(readYamlRunBatch(storage, 'p2')).toBeUndefined(); + storage.setItem('agent-compose:yaml-run:p1', '{"version":2}'); + expect(readYamlRunBatch(storage, 'p1')).toBeUndefined(); + }); + + test('rejects malformed snapshots and normalizes project IDs', () => { + const storage = new MapStorage(); + const batch = createYamlRunBatch('sha256:p1', [{ name: 'a', prompt: 'A' }], 'now', 'batch-1'); + writeYamlRunBatch(storage, batch); + expect(readYamlRunBatch(storage, ' p1 ')).toEqual(batch); + + for (const invalid of [ + { ...batch, projectId: 'p2' }, + { ...batch, completedAt: 1 }, + { ...batch, agents: [{ ...batch.agents[0], status: 'unknown' }] }, + { ...batch, agents: [{ ...batch.agents[0], agentName: '' }] }, + { ...batch, agents: [batch.agents[0], batch.agents[0]] } + ]) { + storage.setItem('agent-compose:yaml-run:p1', JSON.stringify(invalid)); + expect(readYamlRunBatch(storage, 'p1')).toBeUndefined(); + } + }); + + test('catches storage and JSON exceptions', () => { + expect(readYamlRunBatch({ getItem: () => '{' }, 'p1')).toBeUndefined(); + expect(readYamlRunBatch({ getItem: () => { throw new Error('offline'); } }, 'p1')).toBeUndefined(); + expect(() => + writeYamlRunBatch({ setItem: () => { throw new Error('offline'); } }, createYamlRunBatch('p1', [])) + ).not.toThrow(); + }); +}); diff --git a/src/lib/yaml-run-batch.ts b/src/lib/yaml-run-batch.ts new file mode 100644 index 0000000..b50e81f --- /dev/null +++ b/src/lib/yaml-run-batch.ts @@ -0,0 +1,174 @@ +export type BatchAgentStatus = + | 'waiting' + | 'starting' + | 'pending' + | 'running' + | 'succeeded' + | 'failed' + | 'canceled' + | 'start-failed' + | 'start-interrupted'; + +export interface YamlRunBatchAgent { + agentName: string; + prompt: string; + runId: string; + status: BatchAgentStatus; + startError: string; +} + +export interface YamlRunBatch { + version: 1; + batchId: string; + projectId: string; + startedAt: string; + completedAt: string; + agents: YamlRunBatchAgent[]; +} + +const statuses = new Set([ + 'waiting', + 'starting', + 'pending', + 'running', + 'succeeded', + 'failed', + 'canceled', + 'start-failed', + 'start-interrupted' +]); + +const completedStatuses = new Set([ + 'succeeded', + 'failed', + 'canceled', + 'start-failed', + 'start-interrupted' +]); + +const normalizeProjectId = (projectId: string) => projectId.trim().replace(/^sha256:/, ''); + +function createBatchId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID(); + + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export const batchStorageKey = (projectId: string) => + `agent-compose:yaml-run:${normalizeProjectId(projectId)}`; + +export function createYamlRunBatch( + projectId: string, + agents: Array<{ name: string; prompt: string }>, + startedAt = new Date().toISOString(), + batchId: string = createBatchId() +): YamlRunBatch { + return { + version: 1, + batchId, + projectId: normalizeProjectId(projectId), + startedAt, + completedAt: '', + agents: agents.map((agent) => ({ + agentName: agent.name, + prompt: agent.prompt, + runId: '', + status: 'waiting', + startError: '' + })) + }; +} + +export function replaceBatchAgent( + batch: YamlRunBatch, + agentName: string, + patch: Partial +): YamlRunBatch { + const agents = batch.agents.map((agent) => + agent.agentName === agentName ? { ...agent, ...patch } : agent + ); + const completed = agents.length > 0 && agents.every((agent) => completedStatuses.has(agent.status)); + return { + ...batch, + agents, + completedAt: completed ? batch.completedAt || new Date().toISOString() : '' + }; +} + +export function batchStats(batch?: YamlRunBatch): { + total: number; + completed: number; + active: boolean; +} { + if (!batch) return { total: 0, completed: 0, active: false }; + const completed = batch.agents.filter((agent) => completedStatuses.has(agent.status)).length; + return { + total: batch.agents.length, + completed, + active: batch.agents.length > 0 && completed < batch.agents.length + }; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +function decodeBatch(value: unknown, projectId: string): YamlRunBatch | undefined { + if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.agents)) return undefined; + if ( + typeof value.batchId !== 'string' || + typeof value.projectId !== 'string' || + typeof value.startedAt !== 'string' || + typeof value.completedAt !== 'string' || + normalizeProjectId(value.projectId) !== normalizeProjectId(projectId) + ) { + return undefined; + } + + const names = new Set(); + for (const agent of value.agents) { + if ( + !isRecord(agent) || + typeof agent.agentName !== 'string' || + agent.agentName.length === 0 || + names.has(agent.agentName) || + typeof agent.prompt !== 'string' || + typeof agent.runId !== 'string' || + typeof agent.startError !== 'string' || + typeof agent.status !== 'string' || + !statuses.has(agent.status as BatchAgentStatus) + ) { + return undefined; + } + names.add(agent.agentName); + } + + return value as unknown as YamlRunBatch; +} + +export function readYamlRunBatch( + storage: Pick, + projectId: string +): YamlRunBatch | undefined { + try { + const stored = storage.getItem(batchStorageKey(projectId)); + return stored === null ? undefined : decodeBatch(JSON.parse(stored), projectId); + } catch { + return undefined; + } +} + +export function writeYamlRunBatch( + storage: Pick, + batch: YamlRunBatch +): void { + try { + storage.setItem(batchStorageKey(batch.projectId), JSON.stringify(batch)); + } catch { + // Persistence is best-effort (for example, storage may be unavailable or full). + } +} diff --git a/src/lib/yaml-workflow-matrix.test.ts b/src/lib/yaml-workflow-matrix.test.ts new file mode 100644 index 0000000..ef59e09 --- /dev/null +++ b/src/lib/yaml-workflow-matrix.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'vitest'; +import { ProjectChangeAction } from '../gen/agentcompose/v2/agentcompose_pb'; +import { complexYaml, invalidYamlInputs, minimalYaml } from '../../test/fixtures/yaml-workflow-scenarios'; +import { createProjectImageBuildPlans } from './project-image-build'; +import { previewProject, saveProject } from './toolbar-actions'; +import { specToYaml, yamlToSpec } from './yaml'; + +describe('YAML input and expected model matrix', () => { + test('parses a minimal project without inventing Agents', () => { + const result = yamlToSpec(minimalYaml); + expect(result.error).toBeUndefined(); + expect(result.spec.name).toBe('minimal-app'); + expect(result.spec.agents).toEqual([]); + }); + + test('parses Unicode, Agent order, environment, Scheduler, and build data', () => { + const result = yamlToSpec(complexYaml); + expect(result.error).toBeUndefined(); + expect(result.spec.name).toBe('深度测试-app'); + expect(result.spec.agents.map(agent => agent.name)).toEqual(['reviewer', 'scheduled', 'plain']); + expect(result.spec.agents[0]).toMatchObject({ + provider: 'codex', model: 'gpt-test', systemPrompt: '严格审查输入并输出结论', image: 'reviewer:dev', + }); + expect(result.spec.agents[0].env.map(item => [item.name, item.value])).toEqual([['MODE', 'test'], ['EMPTY', '']]); + expect(result.spec.agents[1].scheduler).toMatchObject({ enabled: true, script: 'export default { triggers: [] }' }); + + const [plan] = createProjectImageBuildPlans(result.spec, '/srv/deep/agent-compose.yml'); + expect(plan).toMatchObject({ agentName: 'reviewer', error: '', contextDisplay: './reviewer' }); + expect(plan.request).toMatchObject({ + contextDir: '/srv/deep/reviewer', dockerfile: 'Dockerfile.agent', target: 'runtime', + tags: ['reviewer:dev', 'reviewer:latest'], buildArgs: { MODE: 'production' }, + }); + }); + + test('round-trips the complex YAML without changing semantic fields', () => { + const first = yamlToSpec(complexYaml); + const second = yamlToSpec(specToYaml(first.spec)); + expect(second.error).toBeUndefined(); + expect(second.spec.toJson()).toEqual(first.spec.toJson()); + }); + + for (const scenario of invalidYamlInputs) { + test(`rejects ${scenario.name}`, () => { + const result = yamlToSpec(scenario.yaml); + expect(result.error).toBeTruthy(); + expect(result.spec.name).toBe(''); + }); + } +}); + +describe('YAML preview and Apply response matrix', () => { + test('same hash produces zero real changes while preserving 13 resource details', async () => { + const response = { + applied: false, unchanged: false, revision: { specHash: 'same' }, + changes: Array.from({ length: 13 }, (_, index) => ({ action: ProjectChangeAction.CREATED, name: `resource-${index}` })), + }; + const preview = await previewProject(complexYaml, { applyProject: async () => response as any }, { + currentProjectId: 'project-1', expectedSpecHash: 'same', + projects: [{ summary: { projectId: 'project-1', name: '深度测试-app', sourcePath: '/srv/deep/agent-compose.yml', specHash: 'same' } }], + }); + expect(preview.response.unchanged).toBe(true); + expect(preview.response.changes).toHaveLength(13); + expect(preview.response.changes.filter(change => change.action !== ProjectChangeAction.UNCHANGED)).toEqual([]); + }); + + test('changed hash keeps changes and applies with the preview revision hash', async () => { + const requests: any[] = []; + const preview = await previewProject(complexYaml, { applyProject: async (request) => { + requests.push(request); + return request.dryRun + ? { applied: false, unchanged: false, revision: { specHash: 'new' }, changes: [{ action: ProjectChangeAction.CREATED, name: 'reviewer' }] } as any + : { applied: true, unchanged: false, project: { summary: { projectId: 'project-1' } }, changes: [] } as any; + } }, { + currentProjectId: 'project-1', expectedSpecHash: 'old', + projects: [{ summary: { projectId: 'project-1', name: '深度测试-app', sourcePath: '/srv/deep/agent-compose.yml', specHash: 'old' } }], + }); + expect(preview.response.unchanged).toBe(false); + expect(preview.response.changes[0].action).toBe(ProjectChangeAction.CREATED); + await preview.apply(); + expect(requests.map(request => [request.dryRun, request.expectedSpecHash])).toEqual([[true, ''], [false, 'new']]); + expect(requests[1].spec.toJson()).toEqual(requests[0].spec.toJson()); + }); + + test('invalid YAML and duplicate names issue no Apply request', async () => { + let calls = 0; + const client = { applyProject: async () => { calls++; return { applied: true } as any; } }; + await expect(saveProject('name: [', client)).rejects.toThrow('YAML 解析错误'); + await expect(saveProject(complexYaml, client, { + currentProjectId: 'current', projects: [ + { summary: { projectId: 'current', name: 'old' } }, + { summary: { projectId: 'other', name: '深度测试-app' } }, + ], + })).rejects.toThrow('已存在'); + expect(calls).toBe(0); + }); + + test('a rejected Apply response produces the exact save failure', async () => { + await expect(saveProject(minimalYaml, { applyProject: async () => ({ applied: false, unchanged: false } as any) })) + .rejects.toThrow('保存未生效'); + }); +}); diff --git a/src/lib/yaml.ts b/src/lib/yaml.ts new file mode 100644 index 0000000..35dc2e5 --- /dev/null +++ b/src/lib/yaml.ts @@ -0,0 +1,149 @@ +import yaml from 'js-yaml'; +import { ProjectSpec } from '../gen/agentcompose/v2/agentcompose_pb'; +import type { JsonObject } from '@bufbuild/protobuf'; + +export type YamlMap = Record; + +export function parseYamlObject(yamlText: string): YamlMap { + const value = yaml.load(yamlText); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('YAML 必须是映射格式'); + } + return structuredClone(value as YamlMap); +} + +export function dumpYamlObject(value: YamlMap): string { + return yaml.dump(value, { indent: 2, lineWidth: -1, noRefs: true }); +} + +function convertAgentsMapToArray(obj: YamlMap): void { + if (obj.agents && typeof obj.agents === 'object' && !Array.isArray(obj.agents)) { + const agentsObj = obj.agents as Record; + obj.agents = Object.entries(agentsObj).map(([name, def]) => { + const agent = def as Record; + // Convert env map to array: { KEY: { value: "v" } } -> [{ name: "KEY", value: "v" }] + if (agent.env && typeof agent.env === 'object' && !Array.isArray(agent.env)) { + const envObj = agent.env as Record; + agent.env = Object.entries(envObj).map(([envName, envDef]) => { + if (envDef && typeof envDef === 'object') { + return { name: envName, ...envDef as Record }; + } + return { name: envName, value: String(envDef ?? '') }; + }); + } + return { name, ...agent }; + }); + } +} + +function envMapToArray(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + return Object.entries(value as Record).map(([name, definition]) => { + if (definition && typeof definition === 'object') return { name, ...definition as Record }; + return { name, value: String(definition ?? '') }; + }); +} + +function convertCanonicalMapsToArrays(obj: YamlMap): void { + if (obj.variables !== undefined) obj.variables = envMapToArray(obj.variables); + if (obj.workspaces && typeof obj.workspaces === 'object' && !Array.isArray(obj.workspaces)) { + obj.workspaces = Object.entries(obj.workspaces as Record).map(([name, workspace]) => ({ name, workspace })); + } + if (obj.volumes && typeof obj.volumes === 'object' && !Array.isArray(obj.volumes)) { + obj.volumes = Object.entries(obj.volumes as Record).map(([key, definition]) => ({ + key, + ...definition as Record, + })); + } + if (obj.mcps && typeof obj.mcps === 'object' && !Array.isArray(obj.mcps)) { + obj.mcps = Object.entries(obj.mcps as Record).map(([name, definition]) => { + const mcp: Record = { name, ...definition as Record }; + if (mcp.env !== undefined) mcp.env = envMapToArray(mcp.env); + if (mcp.headers !== undefined) mcp.headers = envMapToArray(mcp.headers); + return mcp; + }); + } +} + +export function yamlToSpec(yamlText: string): { spec: ProjectSpec; error?: string } { + try { + const obj = parseYamlObject(yamlText); + convertCanonicalMapsToArrays(obj); + convertAgentsMapToArray(obj); + const spec = ProjectSpec.fromJson(obj as unknown as JsonObject); + return { spec }; + } catch (e) { + return { spec: new ProjectSpec(), error: String(e) }; + } +} + +export function specToYaml(spec: ProjectSpec): string { + const obj = spec.toJson() as Record; + // Convert agents from array back to map: [{ name: "x", env: [...] }] -> { x: { env: {...} } } + if (Array.isArray(obj.agents)) { + const agentsMap: Record = {}; + for (const a of obj.agents as Array>) { + const { name, ...rest } = a; + if (!name) continue; + // Convert env array back to map: [{ name: "K", value: "v" }] -> { K: { value: "v" } } + if (Array.isArray(rest.env)) { + const envMap: Record = {}; + for (const e of rest.env as Array>) { + if (e.name) { + const { name: en, ...envRest } = e; + envMap[en as string] = envRest; + } + } + rest.env = envMap; + } + agentsMap[name as string] = rest; + } + obj.agents = agentsMap; + } + // Convert variables from array back to map: [{ name: "x", value: "v" }] -> { x: "v" } + if (Array.isArray(obj.variables)) { + const varsMap: Record = {}; + for (const v of obj.variables as Array>) { + if (v.name) varsMap[v.name as string] = v.value ?? ''; + } + obj.variables = varsMap; + } + if (Array.isArray(obj.workspaces)) { + const workspacesMap: Record = {}; + for (const item of obj.workspaces as Array>) { + if (item.name) workspacesMap[item.name as string] = item.workspace ?? {}; + } + obj.workspaces = workspacesMap; + } + if (Array.isArray(obj.volumes)) { + const volumesMap: Record = {}; + for (const item of obj.volumes as Array>) { + const { key, ...definition } = item; + if (key) volumesMap[key as string] = definition; + } + obj.volumes = volumesMap; + } + if (Array.isArray(obj.mcps)) { + const mcpsMap: Record = {}; + for (const item of obj.mcps as Array>) { + const { name, ...definition } = item; + if (name) mcpsMap[name as string] = definition; + } + obj.mcps = mcpsMap; + } + return yaml.dump(obj, { indent: 2, lineWidth: -1, noRefs: true }); +} + +export function countAgents(yamlText: string): number { + const { spec } = yamlToSpec(yamlText); + return spec.agents.length; +} + +export function countSchedulers(yamlText: string): number { + const { spec } = yamlToSpec(yamlText); + return spec.agents.filter(a => a.scheduler).length; +} + +export const EMPTY_YAML_TEMPLATE = `name: "" +agents: [] +`; diff --git a/src/main.ts b/src/main.ts index b22de7e..14faed4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,6 @@ import { mount } from 'svelte'; -import 'xterm/css/xterm.css'; - import App from './App.svelte'; -import './styles.css'; - -const app = mount(App, { - target: document.getElementById('app')!, -}); +import './app.css'; +const app = mount(App, { target: document.getElementById('app')! }); export default app; diff --git a/src/modals/ImageOperations.test.js b/src/modals/ImageOperations.test.js new file mode 100644 index 0000000..04768e8 --- /dev/null +++ b/src/modals/ImageOperations.test.js @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { ImageOperationStatus } from '../gen/agentcompose/v2/agentcompose_pb'; +import { readFileSync } from 'node:fs'; + +const pull = readFileSync(new URL('./PullImageModal.svelte', import.meta.url), 'utf8'); +const remove = readFileSync(new URL('./RemoveImageModal.svelte', import.meta.url), 'utf8'); +const list = readFileSync(new URL('../pages/ImageListView.svelte', import.meta.url), 'utf8'); + +test('pulls an image with store and optional platform in the current request', () => { + assert.match(pull, /new PullImageRequest/); + assert.match(pull, /imageRef/); + assert.match(pull, /store: storeKind/); + assert.match(pull, /new ImagePlatform/); + assert.match(pull, /当前请求/); + assert.match(pull, /response\.progress/); + assert.match(pull, /response\.warnings/); +}); + +test('removes an image with explicit dangerous options and response evidence', () => { + assert.match(remove, /removeImagesSequentially/); + assert.match(remove, /pendingImages/); + assert.match(remove, /重试失败项/); + assert.match(remove, /force/); + assert.match(remove, /pruneChildren/); + assert.match(remove, /window\.confirm/); + assert.match(remove, /untaggedRefs/); + assert.match(remove, /deletedIds/); + assert.match(remove, /warnings/); +}); + +test('build request includes every v2 option and streaming events remain visible', async () => { + const { createBuildImageRequest, consumeBuildImageEvents } = await import('./build-image'); + const request = createBuildImageRequest({ + contextDir: '/srv/build/demo', dockerfile: 'Containerfile', tagsText: 'demo:dev\ndemo:latest', + buildArgsText: 'MODE=release\nEMPTY=', target: 'runtime', store: 1, + os: 'linux', architecture: 'arm64', variant: 'v8', noCache: true, pull: true, + }); + assert.equal(request.contextDir, '/srv/build/demo'); + assert.equal(request.dockerfile, 'Containerfile'); + assert.deepEqual(request.tags, ['demo:dev', 'demo:latest']); + assert.deepEqual(request.buildArgs, { MODE: 'release', EMPTY: '' }); + assert.equal(request.target, 'runtime'); + assert.equal(request.store, 1); + assert.deepEqual( + { os: request.platform.os, architecture: request.platform.architecture, variant: request.platform.variant }, + { os: 'linux', architecture: 'arm64', variant: 'v8' }, + ); + assert.equal(request.noCache, true); + assert.equal(request.pull, true); + + async function* events() { + yield { stage: 'resolve', message: 'reading Dockerfile', warnings: ['legacy syntax'] }; + yield { stage: 'export', message: 'done', imageRef: 'demo:dev', resolvedRef: 'sha256:abc', image: { imageId: 'abc' }, warnings: [] }; + } + const snapshots = []; + const result = await consumeBuildImageEvents(events(), (state) => snapshots.push(structuredClone(state))); + assert.deepEqual(result.lines.map((line) => [line.stage, line.message]), [['resolve', 'reading Dockerfile'], ['export', 'done']]); + assert.deepEqual(result.warnings, ['legacy syntax']); + assert.equal(result.imageRef, 'demo:dev'); + assert.equal(result.resolvedRef, 'sha256:abc'); + assert.equal(result.image.imageId, 'abc'); + assert.equal(snapshots.length, 2); +}); + +test('keeps partial stream output when the build disconnects', async () => { + const { consumeBuildImageEvents } = await import('./build-image'); + async function* events() { yield { stage: 'build', message: 'step 1' }; throw new Error('connection lost'); } + const snapshots = []; + await assert.rejects(consumeBuildImageEvents(events(), (state) => snapshots.push(structuredClone(state))), /connection lost/); + assert.equal(snapshots.at(-1).lines[0].message, 'step 1'); +}); + +test('treats a FAILED terminal event as visible failure while preserving prior output', async () => { + const { consumeBuildImageEvents, BuildImageFailedError } = await import('./build-image'); + async function* events() { + yield { status: ImageOperationStatus.RUNNING, stage: 'build', message: 'step 1', warnings: ['cached'] }; + yield { status: ImageOperationStatus.FAILED, stage: 'export', message: 'builder failed', resolvedRef: 'sha256:partial' }; + } + const snapshots = []; + await assert.rejects(consumeBuildImageEvents(events(), (state) => snapshots.push(structuredClone(state))), BuildImageFailedError); + assert.deepEqual(snapshots.at(-1).lines.map((line) => line.message), ['step 1', 'builder failed']); + assert.deepEqual(snapshots.at(-1).warnings, ['cached']); + assert.equal(snapshots.at(-1).resolvedRef, 'sha256:partial'); +}); + +test('keeps daemon images management-only while project YAML owns builds', () => { + assert.match(list, /new InspectImageRequest/); + assert.match(list, /includeCacheStatus: true/); + assert.match(list, /imageService\.inspectImage/); + assert.match(list, /已有镜像/); + assert.doesNotMatch(list, />构建镜像 + import { imageService } from '../lib/rpc'; + import { store } from '../lib/stores.svelte'; + import { ImageOperationStatus, ImagePlatform, ImageStoreKind, PullImageRequest, type PullImageResponse } from '../gen/agentcompose/v2/agentcompose_pb'; + + let { onclose, oncomplete }: { onclose: () => void; oncomplete: () => void } = $props(); + let imageRef = $state(''); + let storeKind: ImageStoreKind = $state(ImageStoreKind.DOCKER_DAEMON); + let os = $state(''); let architecture = $state(''); let variant = $state(''); + let running = $state(false); let error = $state(''); let response: PullImageResponse | undefined = $state(); + + async function submit() { + if (!imageRef.trim() || running) { error = '请输入镜像引用'; return; } + running = true; error = ''; response = undefined; + try { + const platform = os.trim() || architecture.trim() || variant.trim() + ? new ImagePlatform({ os: os.trim(), architecture: architecture.trim(), variant: variant.trim() }) : undefined; + response = await imageService.pullImage(new PullImageRequest({ imageRef: imageRef.trim(), store: storeKind, platform })); + store.addToast(response.status === ImageOperationStatus.SUCCEEDED ? '镜像拉取完成' : '镜像拉取请求已返回', response.status === ImageOperationStatus.FAILED ? 'error' : 'success'); + oncomplete(); + } catch (cause: any) { error = cause?.message || '拉取镜像失败'; } + finally { running = false; } + } + + + + + diff --git a/src/modals/RemoveImageModal.component.test.ts b/src/modals/RemoveImageModal.component.test.ts new file mode 100644 index 0000000..3fe9b8f --- /dev/null +++ b/src/modals/RemoveImageModal.component.test.ts @@ -0,0 +1,45 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { Image, ImageStoreKind, RemoveImageResponse } from '../gen/agentcompose/v2/agentcompose_pb'; +import RemoveImageModal from './RemoveImageModal.svelte'; + +const mocks = vi.hoisted(() => ({ removeImage: vi.fn() })); +vi.mock('../lib/rpc', () => ({ imageService: { removeImage: mocks.removeImage } })); + +const images = ['first', 'second', 'third'].map(name => new Image({ + imageId: `sha256:${name}`, + imageRef: `${name}:dev`, + store: ImageStoreKind.DOCKER_DAEMON, +})); + +beforeEach(() => { + vi.clearAllMocks(); + window.confirm = vi.fn(() => true); + mocks.removeImage.mockImplementation(async (request: { imageRef: string }) => { + if (request.imageRef === 'second:dev') throw new Error('image is in use'); + return new RemoveImageResponse({ deletedIds: [`sha256:${request.imageRef.split(':')[0]}`] }); + }); +}); + +test('continues bulk deletion after a failure and retries only failed images', async () => { + const oncomplete = vi.fn(); + render(RemoveImageModal, { props: { images, onclose: vi.fn(), oncomplete } }); + + await fireEvent.click(screen.getByLabelText('强制删除')); + await fireEvent.click(screen.getByRole('button', { name: '确认删除 3 个镜像' })); + + await waitFor(() => expect(mocks.removeImage).toHaveBeenCalledTimes(3)); + expect(screen.getByText('成功 2')).toBeInTheDocument(); + expect(screen.getByText('失败 1')).toBeInTheDocument(); + expect(screen.getByText('image is in use').closest('.outcome')).toHaveTextContent('second:dev'); + expect(oncomplete).toHaveBeenCalledOnce(); + expect(mocks.removeImage.mock.calls.every(([request]) => request.force === true)).toBe(true); + + mocks.removeImage.mockResolvedValueOnce(new RemoveImageResponse({ deletedIds: ['sha256:second'] })); + await fireEvent.click(screen.getByRole('button', { name: '重试失败项(1)' })); + + await waitFor(() => expect(mocks.removeImage).toHaveBeenCalledTimes(4)); + expect(mocks.removeImage.mock.calls[3][0].imageRef).toBe('second:dev'); + expect(screen.getByText('成功 3')).toBeInTheDocument(); + expect(screen.getByText('失败 0')).toBeInTheDocument(); +}); diff --git a/src/modals/RemoveImageModal.svelte b/src/modals/RemoveImageModal.svelte new file mode 100644 index 0000000..3dc1d61 --- /dev/null +++ b/src/modals/RemoveImageModal.svelte @@ -0,0 +1,104 @@ + + +
+ + + diff --git a/src/modals/RunAgentModal.component.test.ts b/src/modals/RunAgentModal.component.test.ts new file mode 100644 index 0000000..920a083 --- /dev/null +++ b/src/modals/RunAgentModal.component.test.ts @@ -0,0 +1,110 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import RunAgentModal from './RunAgentModal.svelte'; +import { RunSummary, Sandbox } from '../gen/agentcompose/v2/agentcompose_pb'; + +const mocks = vi.hoisted(() => ({ + runService: { listRuns: vi.fn(), startRun: vi.fn(), runAgentStream: vi.fn(), runAttach: vi.fn(), stopRun: vi.fn() }, + sandboxService: { listSandboxes: vi.fn() }, + store: { activeProjectId: 'project-1', addToast: vi.fn(), navigateTo: vi.fn() }, +})); +vi.mock('../lib/rpc', () => ({ runService: mocks.runService, sandboxService: mocks.sandboxService })); +vi.mock('../lib/stores.svelte', () => ({ store: mocks.store })); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.runService.listRuns.mockResolvedValue({ runs: [ + new RunSummary({ runId: 'run-old', agentName: 'worker', sandboxId: 'sandbox-1', updatedAt: '2026-07-14T00:00:00Z' }), + ] }); + mocks.runService.startRun.mockResolvedValue({ run: new RunSummary({ runId: 'run-new' }) }); + mocks.runService.runAgentStream.mockImplementation(async function* () {}); + mocks.sandboxService.listSandboxes.mockResolvedValue({ sandboxes: [], nextCursor: '' }); +}); +afterEach(() => vi.useRealTimers()); + +async function flushAsync() { for (let index = 0; index < 8; index++) await Promise.resolve(); } +async function setMode(mode: string) { + await fireEvent.change(screen.getByLabelText('输入模式'), { target: { value: mode } }); + await flushAsync(); +} +async function setInput(value: string) { + await fireEvent.input(screen.getByLabelText('运行内容'), { target: { value } }); + await flushAsync(); +} +async function setDriver(value: string) { + await fireEvent.input(screen.getByLabelText('驱动'), { target: { value } }); + await flushAsync(); +} +async function setSandbox(value: string) { + await fireEvent.input(screen.getByLabelText('Sandbox ID'), { target: { value } }); + await flushAsync(); +} +test('hides observation modes and enters Run detail after submission returns a Run ID', async () => { + const onstarted = vi.fn(); + const oncreated = vi.fn(); + const onclose = vi.fn(); + render(RunAgentModal, { prefilledAgent: 'worker', prefilledPrompt: 'hello', onstarted, oncreated, onclose }); + expect(screen.queryByLabelText('观察方式')).not.toBeInTheDocument(); + expect(screen.queryByText('启动新交互 Run(TTY)')).not.toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: '运行' })); + + await waitFor(() => expect(mocks.store.navigateTo).toHaveBeenCalledWith('run-detail', { agentName: 'worker', runId: 'run-new' })); + expect(onstarted).toHaveBeenCalledWith('run-new'); + expect(oncreated).toHaveBeenCalledTimes(1); + expect(onclose).toHaveBeenCalledTimes(1); + expect(onclose.mock.invocationCallOrder[0]).toBeLessThan(mocks.store.navigateTo.mock.invocationCallOrder[0]); +}); + +test('keeps the modal open and reports an error when detached submission returns no Run ID', async () => { + mocks.runService.startRun.mockResolvedValue({ run: new RunSummary() }); + const onstarted = vi.fn(); + const oncreated = vi.fn(); + const onclose = vi.fn(); + render(RunAgentModal, { prefilledAgent: 'worker', prefilledPrompt: 'hello', onstarted, oncreated, onclose }); + + await fireEvent.click(screen.getByRole('button', { name: '运行' })); + + await waitFor(() => expect(mocks.store.addToast).toHaveBeenCalledWith('运行已提交,但未返回 Run ID', 'error')); + expect(screen.getByRole('dialog', { name: '手动运行 worker' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '运行' })).toBeEnabled(); + expect(onstarted).not.toHaveBeenCalled(); + expect(oncreated).not.toHaveBeenCalled(); + expect(onclose).not.toHaveBeenCalled(); + expect(mocks.store.navigateTo).not.toHaveBeenCalled(); +}); + +test('submits command, sandbox ID, and explicit driver override from the form', async () => { + render(RunAgentModal, { prefilledAgent: 'worker' }); + await setMode('command'); + await setInput('bun test'); + await setDriver('docker'); + await setSandbox('sandbox-1'); + await fireEvent.click(screen.getByRole('button', { name: '运行' })); + await waitFor(() => expect(mocks.runService.startRun).toHaveBeenCalledTimes(1)); + const request = mocks.runService.startRun.mock.calls[0][0].run; + expect(request).toMatchObject({ prompt: '', command: 'bun test', sandboxId: 'sandbox-1', driver: 'docker' }); +}); + +test('disables the run button and sends no run RPC when the run content is empty', async () => { + render(RunAgentModal, { prefilledAgent: 'worker' }); + expect(screen.getByRole('button', { name: '运行' })).toBeDisabled(); + await fireEvent.click(screen.getByRole('button', { name: '运行' })); + expect(mocks.runService.startRun).not.toHaveBeenCalled(); +}); + +test('populates driver and sandbox options from the agent existing sandboxes', async () => { + mocks.sandboxService.listSandboxes.mockResolvedValue({ sandboxes: [ + new Sandbox({ sandboxId: 'sb-1', projectId: 'project-1', agentName: 'worker', driver: 'docker' }), + new Sandbox({ sandboxId: 'sb-2', projectId: 'project-1', agentName: 'worker', driver: 'microsandbox' }), + new Sandbox({ sandboxId: 'sb-3', projectId: 'project-1', agentName: 'other', driver: 'docker' }), + ], nextCursor: '' }); + render(RunAgentModal, { prefilledAgent: 'worker', prefilledPrompt: 'hello' }); + + await waitFor(() => expect(mocks.sandboxService.listSandboxes).toHaveBeenCalled()); + await flushAsync(); + const driverValues = Array.from(document.querySelectorAll('#run-agent-driver-options option'), (option) => option.getAttribute('value')); + const sandboxValues = Array.from(document.querySelectorAll('#run-agent-sandbox-options option'), (option) => option.getAttribute('value')); + expect(driverValues).toEqual(['docker', 'microsandbox']); + expect(sandboxValues).toEqual(['sb-1', 'sb-2']); +}); diff --git a/src/modals/RunAgentModal.svelte b/src/modals/RunAgentModal.svelte new file mode 100644 index 0000000..81fde45 --- /dev/null +++ b/src/modals/RunAgentModal.svelte @@ -0,0 +1,264 @@ + + + + + + + + diff --git a/src/modals/RunAgentModal.test.js b/src/modals/RunAgentModal.test.js new file mode 100644 index 0000000..bcbde33 --- /dev/null +++ b/src/modals/RunAgentModal.test.js @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync( + new URL('./RunAgentModal.svelte', import.meta.url), + 'utf8', +); + +test('submits one detached run and reports its returned run ID', () => { + assert.match(source, /onstarted\?: \(runId: string\) => void;/); + assert.match( + source, + /runService\.startRun\(new StartRunRequest\(\{ run: req \}\)\)[\s\S]*const runId = response\.run\?\.runId \|\| '';[\s\S]*onstarted\(runId\);/, + ); + assert.match(source, /store\.navigateTo\('run-detail', \{ agentName, runId \}\)/); +}); + +test('offers prompt and command inputs with explicit run overrides', () => { + assert.match(source, /Prompt(对话)/); + assert.match(source, /命令(Command)/); + assert.match(source, /aria-label="驱动"/); + assert.match(source, /aria-label="Sandbox ID"/); + assert.match(source, /aria-label="清理策略"/); + assert.match(source, /aria-label="启用 Jupyter"/); + assert.match(source, /aria-label="暴露 Jupyter 端口"/); +}); + +test('keeps observation and stop controls on the run detail surface', () => { + assert.doesNotMatch(source, /runAgentStream|runAttach|stopRun/); + assert.doesNotMatch(source, /观察方式|停止中|取消交互 Run/); +}); + +test('uses one submit-then-observe mode', () => { + assert.match(source, /runService\.startRun/); + assert.match(source, /store\.navigateTo\('run-detail'/); + assert.doesNotMatch(source, /runAgentStream|submitMode/); +}); + +test('loads sandbox choices from the authoritative sandbox service', () => { + assert.match(source, /listAllSandboxes\(\(request, options\) => sandboxService\.listSandboxes\(request, options\)\)/); + assert.match(source, /filterSandboxes\(records, \{ projectId, agentName: name \}\)/); + assert.match(source, /if \(cancelled\) return;/); + assert.match(source, /return \(\) => \{ cancelled = true; \};/); +}); + +test('does not derive sandbox choices from run history', () => { + assert.doesNotMatch(source, /listRuns|ListRunsRequest/); + assert.doesNotMatch(source, /Run 记录|最多查询 1000 条/); +}); diff --git a/src/modals/build-image.ts b/src/modals/build-image.ts new file mode 100644 index 0000000..a2c76b2 --- /dev/null +++ b/src/modals/build-image.ts @@ -0,0 +1,55 @@ +import { BuildImageRequest, ImageOperationStatus, ImagePlatform, type BuildImageEvent } from '../gen/agentcompose/v2/agentcompose_pb'; + +export class BuildImageFailedError extends Error { + constructor(message: string) { super(message || '镜像构建失败'); this.name = 'BuildImageFailedError'; } +} + +export interface BuildImageForm { + contextDir: string; dockerfile: string; tagsText: string; buildArgsText: string; target: string; + store: number; os: string; architecture: string; variant: string; noCache: boolean; pull: boolean; +} + +export function createBuildImageRequest(form: BuildImageForm): BuildImageRequest { + const buildArgs: Record = {}; + for (const line of form.buildArgsText.split('\n').map((value) => value.trim()).filter(Boolean)) { + const separator = line.indexOf('='); + if (separator < 1) throw new Error(`构建参数格式错误:${line}`); + buildArgs[line.slice(0, separator).trim()] = line.slice(separator + 1); + } + const platform = form.os.trim() || form.architecture.trim() || form.variant.trim() + ? new ImagePlatform({ os: form.os.trim(), architecture: form.architecture.trim(), variant: form.variant.trim() }) + : undefined; + return new BuildImageRequest({ + contextDir: form.contextDir.trim(), dockerfile: form.dockerfile.trim(), + tags: form.tagsText.split(/[\n,]/).map((value) => value.trim()).filter(Boolean), + buildArgs, target: form.target.trim(), store: form.store, platform, + noCache: form.noCache, pull: form.pull, + }); +} + +export interface BuildStreamState { + lines: Array<{ stage: string; message: string }>; + warnings: string[]; + image?: BuildImageEvent['image']; + imageRef: string; + resolvedRef: string; +} + +export async function consumeBuildImageEvents( + stream: AsyncIterable>, + onUpdate: (state: BuildStreamState) => void, +): Promise { + const state: BuildStreamState = { lines: [], warnings: [], imageRef: '', resolvedRef: '' }; + for await (const event of stream) { + if (event.stage || event.message) state.lines.push({ stage: event.stage || '', message: event.message || '' }); + state.warnings.push(...(event.warnings || [])); + if (event.image) state.image = event.image; + if (event.imageRef) state.imageRef = event.imageRef; + if (event.resolvedRef) state.resolvedRef = event.resolvedRef; + onUpdate(state); + if (event.status === ImageOperationStatus.FAILED) { + throw new BuildImageFailedError(event.message || '镜像构建失败'); + } + } + return state; +} diff --git a/src/model/agents.ts b/src/model/agents.ts deleted file mode 100644 index cb64130..0000000 --- a/src/model/agents.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type AgentDefinition = { - id: string; - name: string; - source: 'system' | 'uploaded'; - description: string; - defaultAgent: string; - workspacePolicy: string; - requiredCapabilities: string[]; - status: string; -}; - -export const builtinAgents: AgentDefinition[] = [ - { - id: 'general-codex', - name: '通用研发助手', - source: 'system', - description: '基于当前 workspace 进行代码阅读、修改建议和任务执行。', - defaultAgent: 'codex', - workspacePolicy: '可选 workspace', - requiredCapabilities: [], - status: '可用', - }, - { - id: 'doc-review', - name: '文档分析助手', - source: 'system', - description: '面向文档摘要、审阅和结构化输出的内置智能体。', - defaultAgent: 'codex', - workspacePolicy: '无需 workspace', - requiredCapabilities: [], - status: '可用', - }, -]; diff --git a/src/model/runs.ts b/src/model/runs.ts deleted file mode 100644 index fe410a4..0000000 --- a/src/model/runs.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { AutomationRun } from '../api/loaders'; -import type { CellType } from '../api/sessions'; -import type { WorkSession } from '../api/sessions'; - -export type ProductRun = { - id: string; - title: string; - type: 'work_session' | 'automation_run'; - status: string; - agentId: string; - agent: string; - automationId: string; - automation: string; - sourceSessionTags: Array<{ name: string; value: string }>; - trigger: string; - capabilitySet: string; - workspace: string; - startedAt: string; - completedAt: string; - duration: string; - rawStatus: string; - agentProvider: string; - messageCount: number; - eventCount: number; - errorSummary: string; - output: string; - input: string; - messages: Array<{ - id?: string; - runId?: string; - renderKey?: string; - role: 'user' | 'agent' | 'system'; - type?: CellType; - agent?: string; - source?: string; - content: string; - at: string; - running?: boolean; - streamingComplete?: boolean; - failed?: boolean; - success?: boolean; - exitCode?: number; - stopReason?: string; - agentSessionId?: string; - }>; - events: Array<{ type: string; level: string; message: string; createdAt: string }>; - artifacts: Array<{ name: string; mimeType: string; size: string; source: string }>; -}; - -export function sessionToRun(session: WorkSession): ProductRun { - const agentID = tagValue(session.tags, 'agent_id'); - const agentName = tagValue(session.tags, 'agent_name') || tagValue(session.tags, 'agent') || tagValue(session.tags, 'agent_definition') || tagValue(session.tags, 'agent_template'); - const loaderID = tagValue(session.tags, 'loader_id'); - const loaderName = tagValue(session.tags, 'loader_name'); - return { - id: session.id, - title: session.title || session.id, - type: 'work_session', - status: mapSessionStatus(session.status), - agentId: agentID, - agent: agentName, - automationId: loaderID, - automation: loaderName || (loaderID ? loaderID : '-'), - sourceSessionTags: session.tags.map((tag) => ({ name: tag.name, value: tag.value })), - trigger: mapTriggerLabel(session.triggerSource || 'manual'), - capabilitySet: '', - workspace: session.workspacePath, - startedAt: session.createdAt, - completedAt: session.updatedAt, - duration: '-', - rawStatus: session.status, - agentProvider: '', - messageCount: Number(session.cellCount || 0), - eventCount: Number(session.eventCount || 0), - errorSummary: '', - output: '', - input: '', - messages: [], - events: [], - artifacts: [], - }; -} - -export function automationRunToRun(run: AutomationRun): ProductRun { - return { - id: run.id, - title: run.id, - type: 'automation_run', - status: mapLoaderRunStatus(run.status), - agentId: '', - agent: '', - automationId: run.loaderId, - automation: run.loaderId, - sourceSessionTags: [], - trigger: mapTriggerLabel(run.triggerSource || run.triggerKind || '-'), - capabilitySet: '', - workspace: '', - startedAt: run.startedAt, - completedAt: run.completedAt, - duration: run.durationMs > 0 ? `${Math.round(run.durationMs / 1000)}s` : '-', - rawStatus: run.status, - agentProvider: '', - messageCount: 0, - eventCount: 0, - errorSummary: run.error, - output: run.resultJson || run.error, - input: run.payloadJson, - messages: [], - events: [], - artifacts: run.artifactsDir ? [{ name: run.artifactsDir, mimeType: 'directory', size: '-', source: 'loader' }] : [], - }; -} - -export function mapSessionStatus(status: string): string { - const normalized = status.toUpperCase(); - if (normalized === 'PENDING') { - return '等待中'; - } - if (normalized === 'STARTING') { - return '启动中'; - } - if (normalized === 'RUNNING') { - return '运行中'; - } - if (normalized === 'FAILED' || normalized === 'START_FAILED') { - return '启动失败'; - } - if (normalized === 'STOPPED') { - return '已停止'; - } - return status || '未知'; -} - -export function mapLoaderRunStatus(status: string): string { - const normalized = status.toUpperCase(); - if (normalized === 'PENDING') { - return '等待中'; - } - if (normalized === 'RUNNING') { - return '运行中'; - } - if (normalized === 'SUCCEEDED' || normalized === 'SUCCESS') { - return '成功'; - } - if (normalized === 'FAILED' || normalized === 'FAILURE') { - return '失败'; - } - if (normalized === 'CANCELED' || normalized === 'CANCELLED') { - return '已取消'; - } - if (normalized === 'SKIPPED') { - return '跳过'; - } - return status || '未知'; -} - -/** - * Canonical status -> color tone for badges/pills across the app. - * In-progress states are blue, terminal-success green, failures red, else gray. - * Expects a localized status label (e.g. from mapSessionStatus / mapLoaderRunStatus). - */ -export function statusTone(status: string): 'blue' | 'green' | 'red' | 'gray' { - if (['启动失败', '失败', '跳过', '已取消'].includes(status)) return 'red'; - if (['成功', '已停止'].includes(status)) return 'green'; - if (['运行中'].includes(status)) return 'blue'; - return 'gray'; -} - -function mapTriggerLabel(value: string): string { - const normalized = value.toLowerCase(); - if (normalized === '1' || normalized.includes('interval')) return '周期触发'; - if (normalized === '2' || normalized.includes('event')) return '事件触发'; - if (normalized === '3' || normalized.includes('timeout')) return '延迟触发'; - if (normalized === '4' || normalized.includes('cron')) return '定时触发'; - if (normalized.includes('manual') || normalized.includes('对话')) return '手动触发'; - return value || '-'; -} - -function tagValue(tags: Array<{ name: string; value: string }>, name: string): string { - return tags.find((tag) => tag.name === name)?.value || ''; -} diff --git a/src/model/runtime.ts b/src/model/runtime.ts deleted file mode 100644 index 93d9cad..0000000 --- a/src/model/runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export const defaultGuestImage = 'ghcr.io/chaitin/agent-compose-guest:latest'; diff --git a/src/model/sandbox-policy.ts b/src/model/sandbox-policy.ts deleted file mode 100644 index 67516d4..0000000 --- a/src/model/sandbox-policy.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type LegacySessionPolicy = 'reuse_session' | 'new_session'; -export type ProjectSandboxPolicy = 'sticky' | 'new'; - -export function toProjectSandboxPolicy(policy: string): ProjectSandboxPolicy { - switch (policy.trim().toLowerCase()) { - case 'sticky': - case 'reuse': - case 'reuse_session': - return 'sticky'; - case 'new': - case 'new_session': - default: - return 'new'; - } -} - -export function toLegacySessionPolicy(policy: string): LegacySessionPolicy { - return toProjectSandboxPolicy(policy) === 'sticky' ? 'reuse_session' : 'new_session'; -} diff --git a/src/model/session-output.ts b/src/model/session-output.ts deleted file mode 100644 index 7ac0ebe..0000000 --- a/src/model/session-output.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { CellType, type WorkSessionCell } from '../api/sessions'; - -export type SessionOutputSearchMatch = { - cellId: string; - section: 'source' | 'output'; - lineIndex: number; - startOffset: number; - endOffset: number; -}; - -export function sessionCellOutput(cell: WorkSessionCell): string { - if (cell.running && !cell.output && !cell.stopReason) return '等待回复...'; - const output = cell.output || cell.stopReason || '-'; - const resultIndex = output.lastIndexOf('__AGENT_RESULT__'); - return resultIndex >= 0 ? output.slice(0, resultIndex) : output; -} - -export function sessionCellStatus(cell: WorkSessionCell): string { - if (cell.id.startsWith('pending-user-')) return '已发送'; - if (cell.running) return '运行中'; - return cell.success ? '完成' : `失败${cell.exitCode ? ` · ${cell.exitCode}` : ''}`; -} - -export function sessionCellStatusTone(cell: WorkSessionCell): 'running' | 'succeeded' | 'failed' { - if (cell.running) return 'running'; - return cell.success ? 'succeeded' : 'failed'; -} - -export function isAgentSessionCell(cell: WorkSessionCell): boolean { - return Boolean(cell.agent) || cell.type === CellType.AGENT || cell.id.startsWith('pending-agent-'); -} - -export function sessionMessageSource(cell: WorkSessionCell): string { - if (cell.id.startsWith('pending-user-')) return cell.output; - return cell.agent && cell.source ? cell.source : ''; -} - -export function sessionMessageOutput(cell: WorkSessionCell): string { - return cell.id.startsWith('pending-user-') ? '' : sessionCellOutput(cell); -} - -export function visibleSessionCells(cells: WorkSessionCell[]): WorkSessionCell[] { - return cells.filter((cell) => Boolean(sessionMessageSource(cell) || sessionMessageOutput(cell))); -} - -export function findSessionOutputMatches(cells: WorkSessionCell[], query: string): SessionOutputSearchMatch[] { - const normalizedQuery = query.trim(); - if (!normalizedQuery) return []; - const pattern = new RegExp(normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'iu'); - const matches: SessionOutputSearchMatch[] = []; - for (const cell of visibleSessionCells(cells)) { - const sections: Array<{ section: SessionOutputSearchMatch['section']; value: string }> = [ - { section: 'source', value: sessionMessageSource(cell) }, - { section: 'output', value: sessionMessageOutput(cell) }, - ]; - for (const { section, value } of sections) { - if (!value) continue; - let lineStartOffset = 0; - value.split(/\r?\n/).forEach((line, lineIndex) => { - const lineMatch = pattern.exec(line); - if (lineMatch) { - const startOffset = lineStartOffset + lineMatch.index; - matches.push({ cellId: cell.id, section, lineIndex, startOffset, endOffset: startOffset + lineMatch[0].length }); - } - lineStartOffset += line.length; - if (value.startsWith('\r\n', lineStartOffset)) lineStartOffset += 2; - else if (value[lineStartOffset] === '\n') lineStartOffset += 1; - }); - } - } - return matches; -} - -export function sessionOutputMatchParts(value: string, match: SessionOutputSearchMatch): Array<{ text: string; matched: boolean }> { - if (match.startOffset < 0 || match.endOffset > value.length) return [{ text: value, matched: false }]; - return [ - { text: value.slice(0, match.startOffset), matched: false }, - { text: value.slice(match.startOffset, match.endOffset), matched: true }, - { text: value.slice(match.endOffset), matched: false }, - ].filter((part) => part.text.length > 0); -} - -export function sessionOutputText(cells: WorkSessionCell[]): string { - const blocks: string[] = []; - for (const cell of visibleSessionCells(cells)) { - const source = sessionMessageSource(cell); - const output = sessionMessageOutput(cell); - if (source) blocks.push(`用户:\n${source}`); - if (output) blocks.push(`${cell.agent || '助手'}:\n${output}`); - } - return blocks.join('\n\n'); -} diff --git a/src/pages/AgentsPage.svelte b/src/pages/AgentsPage.svelte deleted file mode 100644 index 7fe2895..0000000 --- a/src/pages/AgentsPage.svelte +++ /dev/null @@ -1,728 +0,0 @@ - - -{#if error} -
{error}
-{/if} -{#if message} -
{message}
-{/if} - -
-
-
- - - - -
-
- - - - -
-
- - {#if loading} -
正在加载智能体...
- {:else if filteredAgents.length === 0} -
-
- -
- {#if visibleAgents.length === 0 && !keyword} -

还没有智能体

-

智能体定义了运行环境、工作文件与默认配置。创建第一个智能体后即可在这里管理。

-
- -
- {:else} -

没有匹配的智能体

-

当前共有 {visibleAgents.length} 个智能体,但都不满足筛选条件。试试调整或清除关键词。

-
- -
- {/if} -
- {:else} -
-
-
- 智能体 - {filteredAgents.length} 个 -
-
- {#each filteredAgents as agent} - - {/each} -
-
-
- {#if activeAgent} -
-
-

{activeAgent.name || activeAgent.id}

-

{activeAgent.description || '暂无描述'}

-
-
- {agentStatusLabel(activeAgent)} - {activeAgent.health} - - -
-
-
-
-

基础配置

-
-
ID{activeAgent.id || '-'}
-
调用标识{activeAgent.agentName || '-'}
-
Provider{activeAgent.provider || 'codex'}
-
运行驱动{activeAgent.driver || 'docker'}
-
运行镜像{activeAgent.guestImage || defaultGuestImage}
-
创建时间{formatDateTime(activeAgent.createdAt)}
-
更新时间{formatDateTime(activeAgent.updatedAt)}
-
-
-
-

运行摘要

-
-
当前运行{runStatusText(activeAgent)}
-
运行会话{activeAgent.currentRun.runningSessionCount}
-
自动化运行{activeAgent.currentRun.runningLoaderRunCount}
-
最近运行{latestRunText(activeAgent)}
-
工作文件{workFilesText(activeAgent)}
-
-
-
-
-

系统提示词

-
{activeAgent.systemPrompt || '未配置'}
-
-
-
-

环境变量

-
- {#if activeAgent.envItems.length === 0} -
变量未配置
- {:else} - {#each activeAgent.envItems as item} -
{item.name}{item.secret ? '已加密/隐藏' : item.value}
- {/each} - {/if} -
-
-
-

扩展配置

-
{activeAgent.configJson || '{}'}
-
-
- {/if} -
-
- {/if} -
- -{#if editDraft} -
- -{/if} - -{#if runAgent} -
- -{/if} - - diff --git a/src/pages/AutomationTasksPage.svelte b/src/pages/AutomationTasksPage.svelte deleted file mode 100644 index 9eb714d..0000000 --- a/src/pages/AutomationTasksPage.svelte +++ /dev/null @@ -1,1132 +0,0 @@ - - -{#if error} -
{error}
-{/if} -{#if actionMessage} -
{actionMessage}
-{/if} - -
-
-
- - - - -
-
- - - - -
-
- - {#if loading && tasks.length === 0} -
加载中...
- {:else if filteredTasks.length === 0} -
-
- -
- {#if tasks.length === 0} -

还没有自动化任务

-

自动化任务通过触发规则(定时 / 周期 / 事件)自动运行智能体。创建第一个任务后即可在这里管理。

-
- -
- {:else} -

没有匹配的自动化任务

-

当前共有 {tasks.length} 个任务,但都不满足筛选条件。试试调整或清除筛选。

-
- -
- {/if} -
- {:else} -
-
-
- 自动化任务 - {filteredTasks.length} 个 -
-
- {#each filteredTasks as task} - - {/each} -
-
-
- {#if activeTask} - {@const detail = activeDetail} - {@const linkedAgent = agentsById.get(activeTask.agentId)} -
-
-

{activeTask.name || activeTask.id}

-

{activeTask.description || '暂无描述'}

-
-
- {statusLabel(activeTask)} - - - - - -
-
- - {#if detailError} -
{detailError}
- {/if} - -
-
-

基础信息

-
-
ID{activeTask.id || '-'}
-
运行时{runtimeLabel(activeTask)}
-
触发规则{activeTask.triggerCount} 条
-
并发策略{activeTask.concurrencyPolicy === 'parallel' ? '允许并行运行' : '已有运行时跳过'}
-
会话策略{activeTask.sessionPolicy === 'reuse_session' ? '继续使用同一会话' : '每次新建会话'}
-
创建时间{formatDateTime(activeTask.createdAt)}
-
更新时间{formatDateTime(activeTask.updatedAt)}
- {#if activeTask.lastError} -
最近错误{activeTask.lastError}
- {/if} -
-
-
-

关联智能体

-
-
智能体{agentLabel(activeTask.agentId)}
-
调用标识{activeTask.agentName || '-'}
-
Provider{linkedAgent?.provider || activeTask.defaultAgent || '-'}
-
运行驱动{linkedAgent?.driver || activeTask.driver || '默认'}
-
Guest 镜像{activeTask.guestImage || linkedAgent?.guestImage || '默认'}
-
工作区{linkedAgent?.workFiles.workspaceName || linkedAgent?.workspaceId || '默认'}
-
最近运行{activeTask.latestRunAt ? formatDateTime(activeTask.latestRunAt) : '-'}
-
累计运行{activeTask.runCount}
-
-
-
- -
-
-

触发规则

- {#if detailLoadingId === activeTask.id && !detail} - 加载中... - {/if} -
- {#if !detail} -
{detailLoadingId === activeTask.id ? '正在加载触发规则…' : '触发规则未加载'}
- {:else if detail.triggers.length === 0} -
未配置触发规则
- {:else} -
- {#each detail.triggers as trigger} -
-
- {trigger.triggerId || '自动生成触发规则'} -

{triggerKindLabel(trigger.kind)} · {triggerSpec(trigger)}{trigger.nextFireAt ? ` · 下次 ${formatDateTime(trigger.nextFireAt)}` : ''}

-
-
- {trigger.enabled ? '已启用' : '已暂停'} - -
-
- {/each} -
- {/if} -
- -
-
-

环境变量

-
- {#if !detail} -
变量{detailLoadingId === activeTask.id ? '加载中…' : '未加载'}
- {:else if detail.envItems.length === 0} -
变量未配置
- {:else} - {#each detail.envItems as item} -
{item.name}{item.secret ? '已加密/隐藏' : item.value}
- {/each} - {/if} -
-
-
-

能力集

-
- {#if activeTask.capsetIds.length === 0} -
能力集未选择
- {:else} - {#each activeTask.capsetIds as capsetId} - {@const capset = capsets.find((item) => item.id === capsetId)} -
能力集{capset?.name || capsetId}
- {/each} - {/if} -
-
-
- -
-
-

任务脚本

- {#if detail && isScriptTruncated(detailScript(detail))} - - {/if} -
- {#if !detail} -
{detailLoadingId === activeTask.id ? '正在加载脚本…' : '脚本未加载'}
- {:else if !detailScript(detail)} -
未配置脚本
- {:else} -
{@html highlightedJavaScript(scriptExpanded ? detailScript(detail) : previewScript(detailScript(detail)))}
- {#if !scriptExpanded && isScriptTruncated(detailScript(detail))} - 已展示前 14 行,点击“展开”查看完整脚本,或点击右上角“编辑”进行修改。 - {/if} - {/if} -
- {/if} -
-
- {/if} -
- -{#if drawerOpen} -
- -{/if} - -{#if debugTask} -
- -{/if} - - diff --git a/src/pages/CacheListView.svelte b/src/pages/CacheListView.svelte new file mode 100644 index 0000000..67a25a5 --- /dev/null +++ b/src/pages/CacheListView.svelte @@ -0,0 +1,67 @@ + + +
daemon 资源

缓存

管理 daemon 全局缓存,不进入项目 YAML 或 Runtime。

+
+
{#if showDangerousOptions}高风险:可能破坏仍引用这些缓存的任务或沙箱。执行前必须先用相同选项成功预览。{/if}
+ {#if warnings.length}
部分结果{#each warnings as warning}
{warning}
{/each}
{/if} + {#if error}
加载失败:{error}
{:else if loading && caches.length === 0}
正在读取 daemon 缓存…
{:else if caches.length === 0}
没有匹配的缓存
{:else}
ID驱动 / 类型状态大小操作
{#each caches as item (item.cacheId)}
{cacheId(item)}{item.driver || '未提供'} / {item.kind || '未提供'}{cacheDomainLabel(item.domain)}{cacheStatusLabel(item.status)}{formatBytes(item.sizeBytes)}
{/each}
{/if} + {#if inspecting}{:else if inspected?.cache}{/if} + {#if result}

{resultTitle}

后端 dry-run:{result.dryRun ? '是' : '否'}

匹配项 ({result.matched.length})

{#each result.matched as item}
{cacheId(item)}
{#each item.blockedReasons as reason}
阻止原因:{reason}
{/each}{/each}

已移除 ({result.removed.length})

{#each result.removed as id}
{id}
{/each}

跳过项 ({result.skipped.length})

{#each result.skipped as item}
{cacheId(item)}
{#each item.blockedReasons as reason}
阻止原因:{reason}
{/each}{/each}{#each result.warnings as warning}
{warning}
{/each}{#if pending === 'prune'}{:else if pending === 'remove'}{/if}
{/if} + {#if operationError}
操作失败:{operationError}
{/if} +
+ + diff --git a/src/pages/CacheVolumeViews.test.ts b/src/pages/CacheVolumeViews.test.ts new file mode 100644 index 0000000..06e46eb --- /dev/null +++ b/src/pages/CacheVolumeViews.test.ts @@ -0,0 +1,387 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import CacheListView from './CacheListView.svelte'; +import VolumeListView from './VolumeListView.svelte'; +import { CacheDomain, CacheItem, CacheStatus, Volume } from '../gen/agentcompose/v2/agentcompose_pb'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +const rpc = vi.hoisted(() => ({ + cacheService: { listCaches: vi.fn(), inspectCache: vi.fn(), pruneCaches: vi.fn(), removeCache: vi.fn() }, + volumeService: { listVolumes: vi.fn(), inspectVolume: vi.fn(), createVolume: vi.fn(), removeVolume: vi.fn(), pruneVolumes: vi.fn() }, +})); + +vi.mock('../lib/rpc', () => rpc); + +const cache = new CacheItem({ + cacheId: 'cache-1', driver: 'docker', domain: CacheDomain.RUNTIME_DERIVED_CACHE, + kind: 'build', status: CacheStatus.UNUSED, removable: true, blockedReasons: ['仍被任务引用'], warnings: ['缓存警告'], +}); +const volume = new Volume({ name: 'data-one', driver: 'local', projectId: 'project-1', path: '/volumes/data-one' }); + +beforeEach(() => { + vi.clearAllMocks(); + rpc.cacheService.listCaches.mockResolvedValue({ caches: [cache], warnings: ['列表部分失败'] }); + rpc.cacheService.inspectCache.mockResolvedValue({ cache, warnings: ['详情警告'] }); + rpc.volumeService.listVolumes.mockResolvedValue({ volumes: [volume] }); + rpc.volumeService.inspectVolume.mockResolvedValue({ volume }); +}); + +describe('CacheListView', () => { + test('keeps the newest cache list and inspect responses when older requests resolve last', async () => { + const oldList = deferred(); + const oldInspect = deferred(); + const newest = new CacheItem({ cacheId: 'cache-new' }); + rpc.cacheService.listCaches.mockReturnValueOnce(oldList.promise).mockResolvedValueOnce({ caches: [newest], warnings: [] }); + rpc.cacheService.inspectCache.mockReturnValueOnce(oldInspect.promise).mockResolvedValueOnce({ cache: newest, warnings: [] }); + render(CacheListView); + await fireEvent.click(screen.getByRole('button', { name: '应用筛选' })); + expect(await screen.findByText('cache-new')).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: /检查 cache-new/ })); + await waitFor(() => expect(rpc.cacheService.inspectCache).toHaveBeenCalledTimes(1)); + await fireEvent.click(screen.getByRole('button', { name: /检查 cache-new/ })); + oldList.resolve({ caches: [cache], warnings: ['stale'] }); + oldInspect.resolve({ cache, warnings: [] }); + await Promise.resolve(); + expect(screen.queryByText('cache-1')).not.toBeInTheDocument(); + expect(screen.getByText('缓存详情:cache-new')).toBeInTheDocument(); + }); + test('constructs filters, inspects, and preserves partial list warnings', async () => { + render(CacheListView); + await screen.findByText('cache-1'); + expect(screen.getByText('列表部分失败')).toBeInTheDocument(); + + await fireEvent.input(screen.getByLabelText('缓存驱动'), { target: { value: 'docker' } }); + await fireEvent.change(screen.getByLabelText('缓存域'), { target: { value: String(CacheDomain.RUNTIME_DERIVED_CACHE) } }); + await fireEvent.input(screen.getByLabelText('缓存类型'), { target: { value: 'build' } }); + await fireEvent.change(screen.getByLabelText('缓存状态'), { target: { value: String(CacheStatus.UNUSED) } }); + await fireEvent.input(screen.getByLabelText('超过天数未使用'), { target: { value: '7' } }); + await fireEvent.click(screen.getByRole('button', { name: '应用筛选' })); + await waitFor(() => expect(rpc.cacheService.listCaches).toHaveBeenCalledTimes(2)); + const request = rpc.cacheService.listCaches.mock.calls.at(-1)![0]; + expect(request.filter.driver).toBe('docker'); + expect(request.filter.domain).toBe(CacheDomain.RUNTIME_DERIVED_CACHE); + expect(request.filter.type).toBe('build'); + expect(request.filter.status).toBe(CacheStatus.UNUSED); + expect(request.filter.olderThanSeconds).toBe(604800n); + + await fireEvent.click(screen.getByRole('button', { name: /检查 cache-1/ })); + expect(rpc.cacheService.inspectCache).toHaveBeenCalledWith(expect.objectContaining({ cacheId: 'cache-1' })); + expect(await screen.findByText('详情警告')).toBeInTheDocument(); + }); + + test('previews then confirms prune and single removal with force only in phase two', async () => { + rpc.cacheService.pruneCaches + .mockResolvedValueOnce({ dryRun: true, matched: [cache], removed: [], skipped: [cache], warnings: ['预览警告'] }) + .mockResolvedValueOnce({ dryRun: false, matched: [cache], removed: ['cache-1'], skipped: [], warnings: ['执行警告'] }); + rpc.cacheService.removeCache + .mockResolvedValueOnce({ dryRun: true, matched: [cache], removed: [], skipped: [cache], warnings: ['删除预览'] }) + .mockResolvedValueOnce({ dryRun: false, matched: [cache], removed: ['cache-1'], skipped: [], warnings: [] }); + render(CacheListView); + await screen.findByText('cache-1'); + + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + expect(rpc.cacheService.pruneCaches.mock.calls[0][0]).toEqual(expect.objectContaining({ force: false })); + expect(await screen.findByText('预览警告')).toBeInTheDocument(); + expect(screen.getAllByText(/仍被任务引用/)).not.toHaveLength(0); + await fireEvent.click(screen.getByRole('button', { name: '确认执行清理' })); + expect(rpc.cacheService.pruneCaches.mock.calls[1][0]).toEqual(expect.objectContaining({ force: true })); + expect(await screen.findByText('cache-1', { selector: '.removed-item' })).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: /删除 cache-1/ })); + expect(rpc.cacheService.removeCache.mock.calls[0][0]).toEqual(expect.objectContaining({ cacheId: 'cache-1', force: false })); + expect(await screen.findAllByText('删除预览')).not.toHaveLength(0); + await fireEvent.click(screen.getByRole('button', { name: '确认删除缓存' })); + expect(rpc.cacheService.removeCache.mock.calls[1][0]).toEqual(expect.objectContaining({ cacheId: 'cache-1', force: true })); + }); + + test('executes cache prune with the successful preview snapshot after filters change', async () => { + rpc.cacheService.pruneCaches + .mockResolvedValueOnce({ dryRun: true, matched: [cache], removed: [], skipped: [], warnings: [] }) + .mockResolvedValueOnce({ dryRun: false, matched: [cache], removed: ['cache-1'], skipped: [], warnings: [] }); + render(CacheListView); + await screen.findByText('cache-1'); + await fireEvent.input(screen.getByLabelText('缓存驱动'), { target: { value: 'preview-driver' } }); + await fireEvent.input(screen.getByLabelText('缓存类型'), { target: { value: 'preview-kind' } }); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + await screen.findByRole('button', { name: '确认执行清理' }); + + await fireEvent.input(screen.getByLabelText('缓存驱动'), { target: { value: 'changed-driver' } }); + await fireEvent.input(screen.getByLabelText('缓存类型'), { target: { value: 'changed-kind' } }); + await fireEvent.click(screen.getByRole('button', { name: '确认执行清理' })); + + const preview = rpc.cacheService.pruneCaches.mock.calls[0][0]; + const execute = rpc.cacheService.pruneCaches.mock.calls[1][0]; + expect(execute.force).toBe(true); + expect(execute.filter.driver).toBe(preview.filter.driver); + expect(execute.filter.type).toBe(preview.filter.type); + }); + + test('keeps referenced caches excluded by default and executes the disclosed dangerous preview snapshot', async () => { + rpc.cacheService.pruneCaches + .mockResolvedValueOnce({ dryRun: true, matched: [cache], removed: [], skipped: [], warnings: [] }) + .mockResolvedValueOnce({ dryRun: false, matched: [cache], removed: ['cache-1'], skipped: [], warnings: [] }); + render(CacheListView); + await screen.findByText('cache-1'); + expect(screen.queryByRole('checkbox', { name: /仍被引用的缓存/ })).not.toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: '显示危险选项' })); + const includeReferenced = screen.getByRole('checkbox', { name: /仍被引用的缓存/ }); + expect(includeReferenced).not.toBeChecked(); + expect(screen.getByText(/可能破坏仍引用这些缓存的任务或沙箱/)).toBeInTheDocument(); + await fireEvent.click(includeReferenced); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + await screen.findByRole('button', { name: '确认执行清理' }); + expect(rpc.cacheService.pruneCaches.mock.calls[0][0].includeReferenced).toBe(true); + + await fireEvent.click(includeReferenced); + await fireEvent.click(screen.getByRole('button', { name: '确认执行清理' })); + expect(rpc.cacheService.pruneCaches.mock.calls[1][0]).toEqual(expect.objectContaining({ includeReferenced: true, force: true })); + }); + + test('disables includeReferenced when its dangerous disclosure is hidden', async () => { + rpc.cacheService.pruneCaches.mockResolvedValueOnce({ dryRun: true, matched: [], removed: [], skipped: [], warnings: [] }); + render(CacheListView); + await screen.findByText('cache-1'); + await fireEvent.click(screen.getByRole('button', { name: '显示危险选项' })); + await fireEvent.click(screen.getByRole('checkbox', { name: /仍被引用的缓存/ })); + await fireEvent.click(screen.getByRole('button', { name: '隐藏危险选项' })); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + expect(rpc.cacheService.pruneCaches.mock.calls[0][0].includeReferenced).toBe(false); + }); + + test('hiding a dangerous referenced-cache preview revokes its confirmation authorization', async () => { + rpc.cacheService.pruneCaches.mockResolvedValueOnce({ dryRun: true, matched: [cache], removed: [], skipped: [], warnings: [] }); + render(CacheListView); + await screen.findByText('cache-1'); + await fireEvent.click(screen.getByRole('button', { name: '显示危险选项' })); + await fireEvent.click(screen.getByRole('checkbox', { name: /仍被引用的缓存/ })); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + await screen.findByRole('button', { name: '确认执行清理' }); + + await fireEvent.click(screen.getByRole('button', { name: '隐藏危险选项' })); + expect(screen.queryByRole('button', { name: '确认执行清理' })).not.toBeInTheDocument(); + expect(screen.queryByText('清理预览')).not.toBeInTheDocument(); + expect(rpc.cacheService.pruneCaches.mock.calls.filter(([request]) => request.force)).toHaveLength(0); + }); + + test('cannot hide dangerous options while their preview request is in flight', async () => { + const preview = deferred(); + rpc.cacheService.pruneCaches.mockReturnValueOnce(preview.promise); + render(CacheListView); + await screen.findByText('cache-1'); + await fireEvent.click(screen.getByRole('button', { name: '显示危险选项' })); + await fireEvent.click(screen.getByRole('checkbox', { name: /仍被引用的缓存/ })); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + + const hide = screen.getByRole('button', { name: '隐藏危险选项' }); + expect(hide).toBeDisabled(); + await fireEvent.click(hide); + expect(screen.getByRole('checkbox', { name: /仍被引用的缓存/ })).toBeChecked(); + + preview.resolve({ dryRun: true, matched: [cache], removed: [], skipped: [], warnings: [] }); + expect(await screen.findByRole('button', { name: '确认执行清理' })).toBeInTheDocument(); + expect(screen.getByText(/可能破坏仍引用这些缓存/)).toBeInTheDocument(); + }); + + test('revokes old cache removal confirmation when a new preview fails', async () => { + rpc.cacheService.removeCache + .mockResolvedValueOnce({ dryRun: true, matched: [cache], removed: [], skipped: [], warnings: [] }); + rpc.cacheService.pruneCaches.mockRejectedValueOnce(new Error('preview B failed')); + render(CacheListView); + await screen.findByText('cache-1'); + await fireEvent.click(screen.getByRole('button', { name: /删除 cache-1/ })); + await screen.findByRole('button', { name: '确认删除缓存' }); + + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + expect(await screen.findByText(/preview B failed/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '确认删除缓存' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '确认执行清理' })).not.toBeInTheDocument(); + expect(rpc.cacheService.removeCache).toHaveBeenCalledTimes(1); + }); + + test('blocks concurrent cache previews while a deferred preview owns authorization', async () => { + const first = deferred(); + rpc.cacheService.removeCache.mockReturnValueOnce(first.promise).mockResolvedValueOnce({ dryRun: false, matched: [cache], removed: ['cache-1'], skipped: [], warnings: [] }); + render(CacheListView); + await screen.findByText('cache-1'); + const removeButton = screen.getByRole('button', { name: /删除 cache-1/ }); + await fireEvent.click(removeButton); + + expect(removeButton).toBeDisabled(); + expect(screen.getByRole('button', { name: '预览清理' })).toBeDisabled(); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + expect(rpc.cacheService.pruneCaches).not.toHaveBeenCalled(); + + first.resolve({ dryRun: true, matched: [cache], removed: [], skipped: [], warnings: [] }); + const confirm = await screen.findByRole('button', { name: '确认删除缓存' }); + await fireEvent.click(confirm); + await fireEvent.click(confirm); + expect(rpc.cacheService.removeCache.mock.calls.filter(([request]) => request.force)).toHaveLength(1); + }); +}); + +describe('VolumeListView', () => { + test('keeps the newest volume list and inspect responses when older requests resolve last', async () => { + const oldList = deferred(); + const oldInspect = deferred(); + const newest = new Volume({ name: 'volume-new' }); + rpc.volumeService.listVolumes.mockReturnValueOnce(oldList.promise).mockResolvedValueOnce({ volumes: [newest] }); + rpc.volumeService.inspectVolume.mockReturnValueOnce(oldInspect.promise).mockResolvedValueOnce({ volume: newest }); + render(VolumeListView); + await fireEvent.click(screen.getByRole('button', { name: '应用筛选' })); + expect(await screen.findByText('volume-new')).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: /检查 volume-new/ })); + await waitFor(() => expect(rpc.volumeService.inspectVolume).toHaveBeenCalledTimes(1)); + await fireEvent.click(screen.getByRole('button', { name: /检查 volume-new/ })); + oldList.resolve({ volumes: [volume] }); + oldInspect.resolve({ volume }); + await Promise.resolve(); + expect(screen.queryByText('data-one')).not.toBeInTheDocument(); + expect(screen.getByText('数据卷详情:volume-new')).toBeInTheDocument(); + }); + test('constructs list/inspect/create requests and states that YAML is unchanged', async () => { + rpc.volumeService.createVolume.mockResolvedValue({ volume: new Volume({ name: 'created', driver: 'local' }), created: true }); + render(VolumeListView); + await screen.findByText('data-one'); + expect(screen.getAllByText(/不会修改.*YAML/)).not.toHaveLength(0); + + await fireEvent.input(screen.getByLabelText('卷查询'), { target: { value: 'data' } }); + await fireEvent.input(screen.getByLabelText('卷驱动'), { target: { value: 'local' } }); + await fireEvent.input(screen.getByLabelText('项目 ID'), { target: { value: 'project-1' } }); + await fireEvent.click(screen.getByRole('button', { name: '应用筛选' })); + await waitFor(() => expect(rpc.volumeService.listVolumes).toHaveBeenLastCalledWith(expect.objectContaining({ query: 'data', driver: 'local', projectId: 'project-1' }))); + await fireEvent.click(screen.getByRole('button', { name: /检查 data-one/ })); + expect(rpc.volumeService.inspectVolume).toHaveBeenCalledWith(expect.objectContaining({ name: 'data-one' })); + + await fireEvent.input(screen.getByLabelText('新卷名称'), { target: { value: 'created' } }); + await fireEvent.input(screen.getByLabelText('新卷驱动'), { target: { value: 'local' } }); + await fireEvent.click(screen.getByRole('button', { name: '创建卷' })); + expect(rpc.volumeService.createVolume).toHaveBeenCalledWith(expect.objectContaining({ name: 'created', driver: 'local' })); + }); + + test('creates a volume with trimmed label and option maps', async () => { + rpc.volumeService.createVolume.mockResolvedValue({ volume: new Volume({ name: 'created', driver: 'local' }), created: true }); + render(VolumeListView); + await screen.findByText('data-one'); + await fireEvent.input(screen.getByLabelText('新卷名称'), { target: { value: 'created' } }); + await fireEvent.click(screen.getByRole('button', { name: '添加标签' })); + await fireEvent.input(screen.getByLabelText('标签键 1'), { target: { value: ' team ' } }); + await fireEvent.input(screen.getByLabelText('标签值 1'), { target: { value: ' platform ' } }); + await fireEvent.click(screen.getByRole('button', { name: '添加选项' })); + await fireEvent.input(screen.getByLabelText('选项键 1'), { target: { value: ' type ' } }); + await fireEvent.input(screen.getByLabelText('选项值 1'), { target: { value: ' tmpfs ' } }); + await fireEvent.click(screen.getByRole('button', { name: '创建卷' })); + + const request = rpc.volumeService.createVolume.mock.calls[0][0]; + expect(request.labels).toEqual({ team: 'platform' }); + expect(request.options).toEqual({ type: 'tmpfs' }); + expect(request.labels).not.toBeInstanceOf(Map); + }); + + test('rejects empty and duplicate trimmed volume map keys before sending a request', async () => { + render(VolumeListView); + await screen.findByText('data-one'); + await fireEvent.input(screen.getByLabelText('新卷名称'), { target: { value: 'created' } }); + await fireEvent.click(screen.getByRole('button', { name: '添加标签' })); + await fireEvent.click(screen.getByRole('button', { name: '添加标签' })); + await fireEvent.input(screen.getByLabelText('标签键 2'), { target: { value: ' ' } }); + await fireEvent.click(screen.getByRole('button', { name: '创建卷' })); + expect(await screen.findByText('标签键不能为空')).toBeInTheDocument(); + expect(rpc.volumeService.createVolume).not.toHaveBeenCalled(); + + await fireEvent.input(screen.getByLabelText('标签键 1'), { target: { value: 'team' } }); + await fireEvent.input(screen.getByLabelText('标签键 2'), { target: { value: ' team ' } }); + await fireEvent.click(screen.getByRole('button', { name: '创建卷' })); + expect(await screen.findByText('标签键不能重复:team')).toBeInTheDocument(); + expect(rpc.volumeService.createVolume).not.toHaveBeenCalled(); + }); + + test('previews then confirms prune and removal using false then true force', async () => { + rpc.volumeService.pruneVolumes + .mockResolvedValueOnce({ dryRun: true, matched: [volume], removed: [], skipped: [volume] }) + .mockResolvedValueOnce({ dryRun: false, matched: [volume], removed: [volume], skipped: [] }); + rpc.volumeService.removeVolume + .mockResolvedValueOnce({ name: 'data-one', removed: false }) + .mockResolvedValueOnce({ name: 'data-one', removed: true }); + render(VolumeListView); + await screen.findByText('data-one'); + + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + expect(rpc.volumeService.pruneVolumes.mock.calls[0][0]).toEqual(expect.objectContaining({ force: false })); + expect(await screen.findByText('匹配项 (1)')).toBeInTheDocument(); + expect(screen.getByText('跳过项 (1)')).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: '确认执行清理' })); + expect(rpc.volumeService.pruneVolumes.mock.calls[1][0]).toEqual(expect.objectContaining({ force: true })); + expect(await screen.findByText('已移除 (1)')).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('button', { name: /删除 data-one/ })); + expect(rpc.volumeService.removeVolume.mock.calls[0][0]).toEqual(expect.objectContaining({ name: 'data-one', force: false })); + expect(await screen.findByText(/预览结果.*尚未移除/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: '确认删除卷' })); + expect(rpc.volumeService.removeVolume.mock.calls[1][0]).toEqual(expect.objectContaining({ name: 'data-one', force: true })); + expect(await screen.findByText(/执行结果.*已移除/)).toBeInTheDocument(); + }); + + test('executes volume prune with the successful preview snapshot after filters change', async () => { + rpc.volumeService.pruneVolumes + .mockResolvedValueOnce({ dryRun: true, matched: [volume], removed: [], skipped: [] }) + .mockResolvedValueOnce({ dryRun: false, matched: [volume], removed: [volume], skipped: [] }); + render(VolumeListView); + await screen.findByText('data-one'); + await fireEvent.input(screen.getByLabelText('卷查询'), { target: { value: 'preview-query' } }); + await fireEvent.input(screen.getByLabelText('卷驱动'), { target: { value: 'preview-driver' } }); + await fireEvent.input(screen.getByLabelText('项目 ID'), { target: { value: 'preview-project' } }); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + await screen.findByRole('button', { name: '确认执行清理' }); + + await fireEvent.input(screen.getByLabelText('卷查询'), { target: { value: 'changed-query' } }); + await fireEvent.input(screen.getByLabelText('卷驱动'), { target: { value: 'changed-driver' } }); + await fireEvent.input(screen.getByLabelText('项目 ID'), { target: { value: 'changed-project' } }); + await fireEvent.click(screen.getByRole('button', { name: '确认执行清理' })); + + const preview = rpc.volumeService.pruneVolumes.mock.calls[0][0]; + const execute = rpc.volumeService.pruneVolumes.mock.calls[1][0]; + expect(execute).toEqual(expect.objectContaining({ force: true, query: preview.query, driver: preview.driver, projectId: preview.projectId })); + }); + + test('revokes old volume prune confirmation when a removal preview fails', async () => { + rpc.volumeService.pruneVolumes.mockResolvedValueOnce({ dryRun: true, matched: [volume], removed: [], skipped: [] }); + rpc.volumeService.removeVolume.mockRejectedValueOnce(new Error('remove preview failed')); + render(VolumeListView); + await screen.findByText('data-one'); + await fireEvent.click(screen.getByRole('button', { name: '预览清理' })); + await screen.findByRole('button', { name: '确认执行清理' }); + + await fireEvent.click(screen.getByRole('button', { name: /删除 data-one/ })); + expect(await screen.findByText(/remove preview failed/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '确认执行清理' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '确认删除卷' })).not.toBeInTheDocument(); + expect(rpc.volumeService.pruneVolumes).toHaveBeenCalledTimes(1); + }); + + test('blocks concurrent volume previews and double confirm while a request is deferred', async () => { + const preview = deferred(); + const execute = deferred(); + rpc.volumeService.pruneVolumes.mockReturnValueOnce(preview.promise).mockReturnValueOnce(execute.promise); + render(VolumeListView); + await screen.findByText('data-one'); + const pruneButton = screen.getByRole('button', { name: '预览清理' }); + await fireEvent.click(pruneButton); + + expect(pruneButton).toBeDisabled(); + expect(screen.getByRole('button', { name: /删除 data-one/ })).toBeDisabled(); + await fireEvent.click(screen.getByRole('button', { name: /删除 data-one/ })); + expect(rpc.volumeService.removeVolume).not.toHaveBeenCalled(); + + preview.resolve({ dryRun: true, matched: [volume], removed: [], skipped: [] }); + const confirm = await screen.findByRole('button', { name: '确认执行清理' }); + await fireEvent.click(confirm); + await fireEvent.click(confirm); + expect(rpc.volumeService.pruneVolumes.mock.calls.filter(([request]) => request.force)).toHaveLength(1); + execute.resolve({ dryRun: false, matched: [volume], removed: [volume], skipped: [] }); + }); +}); diff --git a/src/pages/Dashboard.component.test.ts b/src/pages/Dashboard.component.test.ts new file mode 100644 index 0000000..f6d7bc5 --- /dev/null +++ b/src/pages/Dashboard.component.test.ts @@ -0,0 +1,72 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { Timestamp } from '@bufbuild/protobuf'; +import Dashboard from './Dashboard.svelte'; +import { DashboardOverview, RunOverview } from '../gen/agentcompose/v2/agentcompose_pb'; + +const mocks = vi.hoisted(() => ({ + dashboardService: { getDashboardOverview: vi.fn(), watchDashboardOverview: vi.fn() }, +})); +vi.mock('../lib/rpc', () => ({ dashboardService: mocks.dashboardService })); + +function overview(running: number, recent: number, attention: number, updatedAt: string) { + return new DashboardOverview({ + runs: new RunOverview({ runningCount: running, recentCount: recent, attentionCount: attention }), + updatedAt: Timestamp.fromDate(new Date(updatedAt)), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('loads the dashboard overview, applies watch updates, and aborts the stream on unmount', async () => { + let releaseUpdate!: () => void; + const updateReady = new Promise((resolve) => { releaseUpdate = resolve; }); + let signal: AbortSignal | undefined; + mocks.dashboardService.getDashboardOverview.mockResolvedValue({ + overview: overview(2, 8, 1, '2026-07-15T02:03:04Z'), + }); + mocks.dashboardService.watchDashboardOverview.mockImplementation((_request, options) => { + signal = options.signal; + return { + async *[Symbol.asyncIterator]() { + await updateReady; + yield { overview: overview(5, 13, 3, '2026-07-15T03:04:05Z') }; + await new Promise(() => {}); + }, + }; + }); + + const view = render(Dashboard); + + expect(await screen.findByText('2')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText(/2026/)).toBeInTheDocument(); + releaseUpdate(); + expect(await screen.findByText('5')).toBeInTheDocument(); + expect(screen.getByText('13')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + await waitFor(() => expect(signal).toBeDefined()); + + view.unmount(); + expect(signal?.aborted).toBe(true); +}); + +test('keeps the last successful snapshot when watching fails and retries on demand', async () => { + mocks.dashboardService.getDashboardOverview.mockResolvedValue({ + overview: overview(7, 21, 4, '2026-07-15T04:05:06Z'), + }); + mocks.dashboardService.watchDashboardOverview + .mockImplementationOnce(async function* () { throw new Error('stream offline'); }) + .mockImplementationOnce(async function* () {}); + + render(Dashboard); + + expect(await screen.findByText('21')).toBeInTheDocument(); + expect(await screen.findByText(/stream offline/)).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: '重试' })); + await waitFor(() => expect(mocks.dashboardService.getDashboardOverview).toHaveBeenCalledTimes(2)); +}); diff --git a/src/pages/Dashboard.svelte b/src/pages/Dashboard.svelte new file mode 100644 index 0000000..6b80ea3 --- /dev/null +++ b/src/pages/Dashboard.svelte @@ -0,0 +1,96 @@ + + +
+
+
+

V2 runtime

+

总览

+
+ {#if overview}{/if} +
+ + {#if loading} +
正在加载总览…
+ {/if} + + {#if overview} +
+
当前运行{overview.runs?.runningCount ?? 0}
+
最近运行{overview.runs?.recentCount ?? 0}
+
需要关注{overview.runs?.attentionCount ?? 0}
+
+ {/if} + + {#if error} + + {/if} +
+ + diff --git a/src/pages/DebugRunPage.svelte b/src/pages/DebugRunPage.svelte deleted file mode 100644 index 4d9b66c..0000000 --- a/src/pages/DebugRunPage.svelte +++ /dev/null @@ -1,271 +0,0 @@ - - -{#if error} -
{error}
-{/if} - -
-
-

调试工具

-
-
- - -
-
- -
-
- -
- - -
diff --git a/src/pages/EventDetailPage.svelte b/src/pages/EventDetailPage.svelte deleted file mode 100644 index 3f2ce42..0000000 --- a/src/pages/EventDetailPage.svelte +++ /dev/null @@ -1,1433 +0,0 @@ - - - - {event ? `${event.topic || event.eventId} · 事件运行结果` : '事件运行结果'} - - -
-
-
-

事件运行结果

-

{event?.topic || '事件详情'}

-

{event ? `${event.eventId} · ${event.correlationId || '-'}` : eventId}

-
-
- {#if event} -
- {#if sessionActionStatusText} - {sessionActionStatusText} - {/if} - - - - -
- {/if} - -
-
- - {#if error} -
{error}
- {/if} - - {#if loading && !event} -
正在加载 event 结果...
- {:else if event} -
-
-
-
-

最近会话

-
- {#if sessionTraces.length === 0} -
这个事件没有产生或绑定工作会话。
- {:else} -
- {#each sessionTraces as trace} - {@const sessionId = trace.link.sessionId} -
-
-
-

{trace.session?.title || trace.link.sessionId}

-

Session {trace.link.sessionId}

-
-
- refreshSessionOutput(trace)} - /> -
- -
- {messageInputHint(trace)} - -
-
-
- {/each} -
- {/if} -
-
- - -
- {/if} -
- - diff --git a/src/pages/EventSandboxDetailPage.component.test.ts b/src/pages/EventSandboxDetailPage.component.test.ts new file mode 100644 index 0000000..ca71483 --- /dev/null +++ b/src/pages/EventSandboxDetailPage.component.test.ts @@ -0,0 +1,100 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { Timestamp } from '@bufbuild/protobuf'; +import { readFileSync } from 'node:fs'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import EventSandboxDetailPage from './EventSandboxDetailPage.svelte'; +import { Sandbox } from '../gen/agentcompose/v2/agentcompose_pb'; + +const mocks = vi.hoisted(() => ({ + loadLinks: vi.fn(), + getSandbox: vi.fn(), +})); + +vi.mock('../lib/event-sandbox-links', () => ({ loadEventSandboxLinks: mocks.loadLinks })); +vi.mock('../lib/rpc', () => ({ sandboxService: { getSandbox: mocks.getSandbox } })); +vi.mock('../views/runtime/SandboxDetailView.svelte', async () => ({ + default: (await import('../../test/fixtures/SandboxDetailViewStub.svelte')).default, +})); + +function sandbox(id: string, updatedAt: string, status = 'RUNNING') { + return new Sandbox({ sandboxId: id, projectId: `project-${id}`, title: `Title ${id}`, status, updatedAt: Timestamp.fromDate(new Date(updatedAt)) }); +} + +beforeEach(() => { + vi.clearAllMocks(); + history.replaceState(null, '', '/events/evt-1'); + mocks.loadLinks.mockResolvedValue([ + { sandboxId: 'old', createdAt: '2026-07-16T00:00:00Z' }, + { sandboxId: 'new', createdAt: '2026-07-17T00:00:00Z' }, + ]); + mocks.getSandbox.mockImplementation(async ({ sandboxId }: { sandboxId: string }) => ({ + sandbox: sandboxId === 'new' ? sandbox('new', '2026-07-17T02:00:00Z') : sandbox('old', '2026-07-16T02:00:00Z', 'STOPPED'), + })); +}); + +test('pins the detail region to the flexible third grid row when no notice is rendered', () => { + const source = readFileSync('src/pages/EventSandboxDetailPage.svelte', 'utf8'); + + expect(source).toMatch(/\.detail-region\s*\{[^}]*grid-row:\s*3/); +}); + +test('selects the newest Session and embeds sandbox detail without its breadcrumb', async () => { + render(EventSandboxDetailPage, { props: { eventId: 'evt-1' } }); + + const select = await screen.findByLabelText('Session'); + expect(select).toHaveValue('new'); + expect(select).toHaveTextContent('Title new'); + expect(select).toHaveTextContent('运行中'); + expect(screen.getByTestId('embedded-sandbox')).toHaveAttribute('data-project-id', 'project-new'); + expect(screen.getByTestId('embedded-sandbox')).toHaveAttribute('data-sandbox-id', 'new'); + expect(screen.getByTestId('embedded-sandbox')).toHaveAttribute('data-show-breadcrumb', 'false'); +}); + +test('restores a linked query selection and updates it when changed', async () => { + history.replaceState(null, '', '/events/evt-1?sandboxId=old'); + const replaceState = vi.spyOn(history, 'replaceState'); + render(EventSandboxDetailPage, { props: { eventId: 'evt-1' } }); + + const select = await screen.findByLabelText('Session'); + expect(select).toHaveValue('old'); + await fireEvent.change(select, { target: { value: 'new' } }); + expect(select).toHaveValue('new'); + expect(replaceState).toHaveBeenLastCalledWith(null, '', '/events/evt-1?sandboxId=new'); +}); + +test('retains the selected Session across refresh', async () => { + history.replaceState(null, '', '/events/evt-1?sandboxId=old'); + render(EventSandboxDetailPage, { props: { eventId: 'evt-1' } }); + expect(await screen.findByLabelText('Session')).toHaveValue('old'); + + await fireEvent.click(screen.getByRole('button', { name: '刷新 Session' })); + + await waitFor(() => expect(mocks.loadLinks).toHaveBeenCalledTimes(2)); + expect(screen.getByLabelText('Session')).toHaveValue('old'); +}); + +test('shows an empty state when the Event has no linked Session', async () => { + mocks.loadLinks.mockResolvedValue([]); + render(EventSandboxDetailPage, { props: { eventId: 'evt-1' } }); + expect(await screen.findByText('该事件未关联 Session')).toBeTruthy(); +}); + +test('keeps available Sessions when one linked Sandbox cannot be loaded', async () => { + mocks.getSandbox.mockImplementation(async ({ sandboxId }: { sandboxId: string }) => { + if (sandboxId === 'new') throw new Error('gone'); + return { sandbox: sandbox('old', '2026-07-16T02:00:00Z') }; + }); + render(EventSandboxDetailPage, { props: { eventId: 'evt-1' } }); + + expect(await screen.findByLabelText('Session')).toHaveValue('old'); + expect(screen.getByText('1 个关联 Session 暂时无法加载')).toBeTruthy(); +}); + +test('shows a retryable Event loading error', async () => { + mocks.loadLinks.mockRejectedValueOnce(new Error('event unavailable')); + render(EventSandboxDetailPage, { props: { eventId: 'evt-1' } }); + expect(await screen.findByText('event unavailable')).toBeTruthy(); + await fireEvent.click(screen.getByRole('button', { name: '重试' })); + expect(await screen.findByLabelText('Session')).toHaveValue('new'); +}); diff --git a/src/pages/EventSandboxDetailPage.svelte b/src/pages/EventSandboxDetailPage.svelte new file mode 100644 index 0000000..4031ac4 --- /dev/null +++ b/src/pages/EventSandboxDetailPage.svelte @@ -0,0 +1,150 @@ + + +
+
+
+ Event Session + {eventId} +
+ {#if options.length} + + {/if} + +
+ + {#if unavailableCount} +
{unavailableCount} 个关联 Session 暂时无法加载
+ {/if} + +
+ {#if loading && !selected} +
加载 Event Session 中...
+ {:else if error} +

{error}

+ {:else if !selected} +
该事件未关联 Session
+ {:else} + + {/if} +
+
+ + diff --git a/src/pages/ImageListView.component.test.ts b/src/pages/ImageListView.component.test.ts new file mode 100644 index 0000000..09f7561 --- /dev/null +++ b/src/pages/ImageListView.component.test.ts @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { Image, ImageStoreKind, InspectImageResponse } from '../gen/agentcompose/v2/agentcompose_pb'; +import ImageListView from './ImageListView.svelte'; + +const mocks = vi.hoisted(() => ({ listImages: vi.fn(), inspectImage: vi.fn(), addToast: vi.fn() })); +vi.mock('../lib/rpc', () => ({ imageService: { listImages: mocks.listImages, inspectImage: mocks.inspectImage, removeImage: vi.fn() } })); +vi.mock('../lib/stores.svelte', () => ({ store: { addToast: mocks.addToast } })); + +const finalImage = new Image({ imageId: 'sha256:final', imageRef: 'final:dev', store: ImageStoreKind.DOCKER_DAEMON, dangling: false, sizeBytes: 1024n }); +const intermediateImage = new Image({ imageId: 'sha256:layer', imageRef: 'sha256:layer', store: ImageStoreKind.DOCKER_DAEMON, dangling: true, sizeBytes: 512n }); +const appendedImage = new Image({ imageId: 'sha256:next', imageRef: 'next:dev', store: ImageStoreKind.DOCKER_DAEMON, dangling: false }); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.listImages.mockReset(); + mocks.inspectImage.mockReset(); + mocks.listImages + .mockResolvedValueOnce({ images: [finalImage, intermediateImage], hasMore: true, nextOffset: 2 }) + .mockResolvedValueOnce({ images: [appendedImage], hasMore: false, nextOffset: 3 }); + mocks.inspectImage.mockResolvedValue(new InspectImageResponse({ + image: new Image({ ...finalImage, containerCount: 2n, labels: { purpose: 'test' } }), + storeStatus: { endpoint: '/var/run/docker.sock' }, + })); +}); + +test('keeps pull available by default and only hides its entry when requested', async () => { + const first = render(ImageListView); + expect(await screen.findByRole('button', { name: '拉取镜像' })).toBeInTheDocument(); + first.unmount(); + + render(ImageListView, { showPullAction: false }); + await screen.findByText('成品镜像'); + expect(screen.queryByRole('button', { name: '拉取镜像' })).not.toBeInTheDocument(); +}); + +test('labels image types and expands inspected details directly below the selected row', async () => { + render(ImageListView); + + expect(await screen.findByText('中间层')).toBeInTheDocument(); + expect(screen.getByText('成品镜像')).toBeInTheDocument(); + const trigger = screen.getByRole('button', { name: '展开镜像 final:dev' }); + await fireEvent.click(trigger); + + const detail = await screen.findByTestId('image-detail-final:dev'); + expect(detail.previousElementSibling).toContainElement(screen.getByRole('button', { name: '收起镜像 final:dev' })); + expect(within(detail).getByText('容器数')).toBeInTheDocument(); + expect(within(detail).getByText('2')).toBeInTheDocument(); + expect(detail.querySelector('.inspect-size')).toBeInTheDocument(); + expect(detail.querySelector('.inspect-size')).not.toBeVisible(); + + await fireEvent.click(screen.getByRole('button', { name: '收起镜像 final:dev' })); + expect(screen.queryByTestId('image-detail-final:dev')).toBeNull(); +}); + +test('selects only currently loaded images and opens bulk removal for that selection', async () => { + render(ImageListView); + const selectAll = await screen.findByLabelText('选择当前已加载镜像'); + + await fireEvent.click(selectAll); + expect(screen.getByLabelText('选择镜像 final:dev')).toBeChecked(); + expect(screen.getByLabelText('选择镜像 sha256:layer')).toBeChecked(); + await fireEvent.click(screen.getByRole('button', { name: '加载更多' })); + await waitFor(() => expect(screen.getByLabelText('选择镜像 next:dev')).not.toBeChecked()); + + await fireEvent.click(screen.getByRole('button', { name: '删除所选(2)' })); + expect(screen.getByRole('dialog', { name: '删除镜像' })).toBeInTheDocument(); + expect(within(screen.getByLabelText('待删除镜像')).getByText('final:dev')).toBeInTheDocument(); + expect(within(screen.getByLabelText('待删除镜像')).queryByText('next:dev')).toBeNull(); +}); + +test('keeps inspection failures inline and retries without collapsing the row', async () => { + mocks.inspectImage + .mockRejectedValueOnce(new Error('daemon unavailable')) + .mockResolvedValueOnce(new InspectImageResponse({ image: finalImage })); + render(ImageListView); + + await fireEvent.click(await screen.findByRole('button', { name: '展开镜像 final:dev' })); + expect(await screen.findByText('daemon unavailable')).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: '重试' })); + + expect(await screen.findByTestId('image-detail-final:dev')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '收起镜像 final:dev' })).toBeInTheDocument(); + expect(mocks.inspectImage).toHaveBeenCalledTimes(2); +}); diff --git a/src/pages/ImageListView.svelte b/src/pages/ImageListView.svelte new file mode 100644 index 0000000..26a838f --- /dev/null +++ b/src/pages/ImageListView.svelte @@ -0,0 +1,225 @@ + + +
+
+
{#if showTitle}

镜像

{/if}

查看和管理 daemon 中已有镜像;镜像构建由项目 YAML 声明,并在保存或运行时确认。

+
+ +
+ + + + + {#if selectedImages.length > 0}已选 {selectedImages.length} 项{/if} +
{#if showPullAction}{/if}
+
+ + {#if storeStatus && !storeStatus.available}
存储不可用:{storeStatus.error || '后端未提供原因'}
{/if} + {#if error && images.length === 0} +
{error}
+ {:else if loading && images.length === 0} +
正在读取 daemon 镜像…
+ {:else if images.length === 0} +
没有匹配的镜像
+ {:else} +
+
+ + 镜像引用 / ID类型存储状态平台大小创建时间 +
+ {#each images as image (imageSelectionKey(image))} + {@const ref = imageDisplayRef(image)} + {@const key = imageSelectionKey(image)} +
+ toggleSelected(image)} /> + +
+ {#if expandedKey === key} +
+ {#if inspectingKey === key} +
正在检查镜像…
+ {:else if inspectError} +
{inspectError}
+ {:else if inspected?.image} +
镜像详情

{imageDisplayRef(inspected.image)}

+
ID
{inspected.image.imageId || '后端未提供'}
完整引用
{inspected.image.resolvedRef || imageDisplayRef(inspected.image)}
平台
{formatImagePlatform(inspected.image.platform)}
容器数
{String(inspected.image.containerCount)}
存储端点
{inspected.storeStatus?.endpoint || '后端未提供'}
+
标签{#if Object.keys(inspected.image.labels).length}{#each Object.entries(inspected.image.labels) as [name, value]}{name}={value}{/each}{:else}{/if}
+ {/if} +
+ {/if} + {/each} +
+ {#if hasMore}{/if} + {/if} +
+{#if showPull} showPull = false} oncomplete={() => load(true)} />{/if} +{#if removeTargets.length} removeTargets = []} oncomplete={handleRemovalComplete} />{/if} + + diff --git a/src/pages/ImageListView.test.js b/src/pages/ImageListView.test.js new file mode 100644 index 0000000..960eaf8 --- /dev/null +++ b/src/pages/ImageListView.test.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./ImageListView.svelte', import.meta.url), 'utf8'); + +test('lists daemon images with server-side filters and pagination', () => { + assert.match(source, /new ListImagesRequest/); + assert.match(source, /query: query\.trim\(\)/); + assert.match(source, /store: storeFilter/); + assert.match(source, /all: showIntermediate/); + assert.match(source, /includeCacheStatus: true/); + assert.match(source, /offset: reset \? 0 : nextOffset/); + assert.match(source, /limit: PAGE_SIZE/); + assert.match(source, /resp\.hasMore/); + assert.match(source, /resp\.nextOffset/); +}); + +test('guards list state against stale responses and reports store availability', () => { + assert.match(source, /requestGeneration/); + assert.match(source, /generation !== requestGeneration/); + assert.match(source, /storeStatus/); + assert.match(source, /后端未提供/); +}); + +test('is a management surface and does not offer generic image creation', () => { + assert.match(source, /已有镜像/); + assert.match(source, /PullImageModal/); + assert.match(source, /RemoveImageModal/); + assert.doesNotMatch(source, /BuildImageModal/); + assert.doesNotMatch(source, />构建镜像 { + assert.doesNotMatch(source, /
(?:(?!<\/header>)[\s\S])*?
/); + assert.match(source, /
[\s\S]*?<\/span>[\s\S]*?
[\s\S]*?刷新[\s\S]*?拉取镜像/); +}); + +test('classifies rows, expands details inline, and supports current-page bulk selection', () => { + assert.match(source, /image\.dangling/); + assert.match(source, /中间层/); + assert.match(source, /成品镜像/); + assert.match(source, /selectedKeys/); + assert.match(source, /选择当前已加载镜像/); + assert.match(source, /删除所选/); + assert.match(source, /data-image-detail/); + assert.doesNotMatch(source, /