diff --git a/README.md b/README.md index dbb4c1d..4248d63 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,12 @@ reads JSONL files below `.codex/sessions` and `.claude/projects`. The UI server owns login identity and audit attribution; the daemon does not manage browser users. Local password login is a single emergency account from `AUTH_USERNAME`/`AUTH_PASSWORD`, while OAuth identities are discovered during -login. There is intentionally no user-management API or user CRUD. +login. There is intentionally no user-management API or user CRUD. When neither +password login nor OAuth is configured, browser access is read-only: query APIs +remain available, while mutations, command execution, interactive terminals, +and Jupyter proxy access return HTTP 403. Configure either authentication method +to enable those operations. Webhook ingress and the runtime LLM facade retain +their existing public-access behavior. Database migrations live under `internal/dbmigrate/migrations/` and use one ordered sequence for the UI database. Applied migrations are tracked with diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 943d128..e1a5107 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -44,8 +44,8 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) { w.WriteHeader(http.StatusNoContent) })) defer upstream.Close() - t.Setenv("AUTH_PASSWORD", "") - t.Setenv("AUTH_SECRET", "") + t.Setenv("AUTH_PASSWORD", "secret") + t.Setenv("AUTH_SECRET", "test-secret") t.Setenv("AGENT_COMPOSE_URL", upstream.URL) t.Setenv("UI_DATABASE_PATH", t.TempDir()+"/ui.db") @@ -57,6 +57,7 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) { create := httptest.NewRequest(http.MethodPost, "/api/ui/v1/tokens", strings.NewReader(`{"name":"automation","role":"admin","expiresInDays":90}`)) create.Header.Set("Content-Type", "application/json") + create.SetBasicAuth("admin", "secret") createdResponse := httptest.NewRecorder() browser.ServeHTTP(createdResponse, create) if createdResponse.Code != http.StatusCreated { @@ -70,7 +71,9 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) { t.Fatalf("created response = %q, err = %v", createdResponse.Body.String(), err) } auditResponse := httptest.NewRecorder() - browser.ServeHTTP(auditResponse, httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil)) + auditRequest := httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil) + auditRequest.SetBasicAuth("admin", "secret") + browser.ServeHTTP(auditResponse, auditRequest) if auditResponse.Code != http.StatusOK || !strings.Contains(auditResponse.Body.String(), "POST /api/ui/v1/tokens") { t.Fatalf("audit response = %d: %s", auditResponse.Code, auditResponse.Body.String()) } @@ -83,7 +86,9 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) { t.Fatalf("proxy status = %d: %s", response.Code, response.Body.String()) } auditResponse = httptest.NewRecorder() - browser.ServeHTTP(auditResponse, httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil)) + auditRequest = httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil) + auditRequest.SetBasicAuth("admin", "secret") + browser.ServeHTTP(auditResponse, auditRequest) if auditResponse.Code != http.StatusOK || !strings.Contains(auditResponse.Body.String(), `"id":"token:`+created.ID+`"`) || !strings.Contains(auditResponse.Body.String(), `"displayName":"automation"`) { t.Fatalf("token audit attribution = %d: %s", auditResponse.Code, auditResponse.Body.String()) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9e6a941..92540d4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -129,9 +129,14 @@ func (a *Manager) Protect(next echo.HandlerFunc) echo.HandlerFunc { if !a.enabled { principal := audit.Principal{ID: "local:default", Source: "local", Username: "local", DisplayName: "local", AuthMethod: "disabled"} c.SetRequest(r.WithContext(audit.WithPrincipal(r.Context(), principal))) - return next(c) + if isPublicAuthRequest(r) || isRuntimeLLMFacadeRequest(r) || isReadOnlyRequest(r) { + return next(c) + } + a.record(r, audit.Input{Actor: principal, Category: "authentication", Action: "access.denied", Method: r.Method, Path: r.URL.Path, Outcome: "denied", Status: http.StatusForbidden}) + c.Response().Header().Set("Cache-Control", "no-store") + return echoJSON(c, http.StatusForbidden, map[string]string{"code": "permission_denied", "message": "authentication is required for this operation"}) } - if isPublicAuthPath(r.URL.Path) || isRuntimeLLMFacadeRequest(r) { + if isPublicAuthRequest(r) || isRuntimeLLMFacadeRequest(r) { return next(c) } if !a.protectsPath(r.URL.Path, r.Header.Get("Accept")) { diff --git a/internal/auth/main_test.go b/internal/auth/main_test.go index c14c077..55a1b92 100644 --- a/internal/auth/main_test.go +++ b/internal/auth/main_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "agent-compose-ui/internal/audit" + "github.com/labstack/echo/v4" ) @@ -27,6 +29,92 @@ func TestAuthStatusDisabled(t *testing.T) { } } +func TestDisabledAuthAllowsOnlyReadOnlyRequests(t *testing.T) { + t.Setenv("AUTH_PASSWORD", "") + t.Setenv("OAUTH_APIKEY", "") + + tests := []struct { + name string + method string + path string + want int + }{ + {"query rpc", http.MethodPost, "/agentcompose.v2.RunService/GetRun", http.StatusNoContent}, + {"stream query rpc", http.MethodPost, "/agentcompose.v2.ProjectService/StreamSchedulerRuns", http.StatusNoContent}, + {"health rpc", http.MethodPost, "/health.v1.HealthService/Status", http.StatusNoContent}, + {"rest query", http.MethodGet, "/api/events/event-1/trace", http.StatusNoContent}, + {"ui query", http.MethodGet, "/api/ui/v1/projects/project-1/yaml", http.StatusNoContent}, + {"run mutation", http.MethodPost, "/agentcompose.v2.RunService/RunAgent", http.StatusForbidden}, + {"settings mutation", http.MethodPost, "/agentcompose.v2.SettingsService/UpdateGlobalEnv", http.StatusForbidden}, + {"workspace upload", http.MethodPost, "/api/agent-compose/workspaces/workspace-1/upload", http.StatusForbidden}, + {"terminal websocket", http.MethodGet, "/api/terminal/attach", http.StatusForbidden}, + {"jupyter proxy", http.MethodGet, "/jupyter/sandbox-1/lab", http.StatusForbidden}, + {"legacy jupyter proxy", http.MethodGet, "/agent-compose/session/sandbox-1/lab", http.StatusForbidden}, + {"future api", http.MethodGet, "/api/future-query", http.StatusForbidden}, + {"future mutation", http.MethodPatch, "/future/write-api", http.StatusForbidden}, + {"webhook ingress", http.MethodPost, "/api/webhooks/webhook.github.push", http.StatusNoContent}, + {"webhook non-ingress method", http.MethodDelete, "/api/webhooks/webhook.github.push", http.StatusForbidden}, + {"runtime llm facade", http.MethodPost, "/api/runtime/sandboxes/sandbox-1/llm/openai/v1/responses", http.StatusNoContent}, + {"legacy runtime llm facade", http.MethodPost, "/api/runtime/sessions/sandbox-1/llm/openai/v1/responses", http.StatusNoContent}, + {"malformed runtime llm facade", http.MethodPost, "/api/runtime/sandboxes/sandbox-1/extra/llm/openai/v1/responses", http.StatusForbidden}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + called := false + auth := NewManagerFromEnv() + handler := newTestApp(auth, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(test.method, test.path, nil)) + + if recorder.Code != test.want { + t.Fatalf("status = %d, want %d: %s", recorder.Code, test.want, recorder.Body.String()) + } + if called != (test.want == http.StatusNoContent) { + t.Fatalf("backend called = %t", called) + } + if test.want == http.StatusForbidden { + if recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), `"code":"permission_denied"`) { + t.Fatalf("forbidden response headers=%v body=%s", recorder.Header(), recorder.Body.String()) + } + } + }) + } +} + +func TestDisabledAuthAuditsDeniedOperation(t *testing.T) { + t.Setenv("AUTH_PASSWORD", "") + t.Setenv("OAUTH_APIKEY", "") + store, err := audit.OpenStore(t.TempDir()+"/ui.db", 180) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + + auth := NewManagerFromEnv(store) + handler := newTestApp(auth, http.NotFoundHandler()) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/agentcompose.v2.RunService/RunAgent", nil)) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) + } + + page, err := store.Query(t.Context(), audit.Filter{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 { + t.Fatalf("audit items = %d, want 1", len(page.Items)) + } + event := page.Items[0] + if event.Actor.ID != "local:default" || event.Action != "access.denied" || event.Outcome != "denied" || event.Status != http.StatusForbidden { + t.Fatalf("audit event = %#v", event) + } +} + func TestAuthProtectsRPCAndAcceptsBasicAuth(t *testing.T) { t.Setenv("AUTH_USERNAME", "admin") t.Setenv("AUTH_PASSWORD", "secret") diff --git a/internal/auth/paths.go b/internal/auth/paths.go index a9cde52..9fbcdd6 100644 --- a/internal/auth/paths.go +++ b/internal/auth/paths.go @@ -6,23 +6,38 @@ import ( "strings" ) -func isPublicAuthPath(path string) bool { - if strings.HasPrefix(path, "/api/webhooks/") { - return true +func isPublicAuthRequest(r *http.Request) bool { + switch r.URL.Path { + case "/login": + return r.Method == http.MethodGet || r.Method == http.MethodHead + case "/api/auth/status", "/oauth/authorize", "/oauth/callback": + return r.Method == http.MethodGet || r.Method == http.MethodHead + case "/api/auth/login", "/api/auth/logout": + return r.Method == http.MethodPost + default: + return r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/webhooks/") } - 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")) + for _, prefix := range []string{"/api/runtime/sandboxes/", "/api/runtime/sessions/"} { + if !strings.HasPrefix(r.URL.Path, prefix) { + continue + } + parts := strings.Split(strings.TrimPrefix(r.URL.Path, prefix), "/") + switch { + case len(parts) == 5 && parts[0] != "" && parts[1] == "llm" && parts[2] == "openai" && parts[3] == "v1" && parts[4] == "responses": + return true + case len(parts) == 6 && parts[0] != "" && parts[1] == "llm" && parts[2] == "openai" && parts[3] == "v1" && parts[4] == "chat" && parts[5] == "completions": + return true + case len(parts) == 5 && parts[0] != "" && parts[1] == "llm" && parts[2] == "anthropic" && parts[3] == "v1" && parts[4] == "messages": + return true + } + } + return false } func acceptsHTML(r *http.Request) bool { diff --git a/internal/auth/read_only.go b/internal/auth/read_only.go new file mode 100644 index 0000000..3bf9f2d --- /dev/null +++ b/internal/auth/read_only.go @@ -0,0 +1,99 @@ +package auth + +import ( + "net/http" + "strings" +) + +var readOnlyProcedures = map[string]struct{}{ + "/agentcompose.v2.ProjectService/GetProject": {}, + "/agentcompose.v2.ProjectService/ListProjects": {}, + "/agentcompose.v2.ProjectService/WatchProject": {}, + "/agentcompose.v2.ProjectService/GetScheduler": {}, + "/agentcompose.v2.ProjectService/ListSchedulers": {}, + "/agentcompose.v2.ProjectService/ListSchedulerEvents": {}, + "/agentcompose.v2.ProjectService/ListProjectSchedulerEvents": {}, + "/agentcompose.v2.ProjectService/StreamProjectSchedulerEvents": {}, + "/agentcompose.v2.ProjectService/GetSchedulerRun": {}, + "/agentcompose.v2.ProjectService/ListSchedulerRuns": {}, + "/agentcompose.v2.ProjectService/BatchGetLatestSchedulerRuns": {}, + "/agentcompose.v2.ProjectService/StreamSchedulerRuns": {}, + "/agentcompose.v2.RunService/GetRun": {}, + "/agentcompose.v2.RunService/ListRuns": {}, + "/agentcompose.v2.RunService/FollowRunLogs": {}, + "/agentcompose.v2.RunService/ListRunEvents": {}, + "/agentcompose.v2.RunService/ListSandboxRunEvents": {}, + "/agentcompose.v2.ImageService/ListImages": {}, + "/agentcompose.v2.ImageService/InspectImage": {}, + "/agentcompose.v2.CacheService/ListCaches": {}, + "/agentcompose.v2.CacheService/InspectCache": {}, + "/agentcompose.v2.VolumeService/ListVolumes": {}, + "/agentcompose.v2.VolumeService/InspectVolume": {}, + "/agentcompose.v2.SandboxService/GetSandboxStats": {}, + "/agentcompose.v2.SandboxService/GetSandbox": {}, + "/agentcompose.v2.SandboxService/ListSandboxes": {}, + "/agentcompose.v2.SandboxService/ListSandboxHistory": {}, + "/agentcompose.v2.SandboxService/WatchSandbox": {}, + "/agentcompose.v2.DashboardService/GetDashboardOverview": {}, + "/agentcompose.v2.DashboardService/WatchDashboardOverview": {}, + "/agentcompose.v2.SettingsService/GetGlobalEnv": {}, + "/agentcompose.v2.SettingsService/GetCapabilityGatewayConfig": {}, + "/agentcompose.v2.SettingsService/ListWorkspacePresets": {}, + "/agentcompose.v2.CapabilityService/GetCapabilityStatus": {}, + "/agentcompose.v2.CapabilityService/ListCapabilitySets": {}, + "/agentcompose.v2.CapabilityService/GetCapabilityCatalog": {}, + "/agentcompose.v2.ResourceService/ResolveID": {}, + "/health.v1.HealthService/Status": {}, + "/health.v1.HealthService/WatchStatus": {}, +} + +func isReadOnlyRequest(r *http.Request) bool { + if r.URL.RawPath != "" { + return false + } + if r.Method == http.MethodPost { + _, ok := readOnlyProcedures[r.URL.Path] + return ok + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return false + } + return isReadOnlyRESTPath(r.URL.Path) || isReadOnlyUIPath(r.URL.Path) +} + +func isReadOnlyUIPath(path string) bool { + if path == "/api/ui/v1/projects" || path == "/api/ui/v1/runs/unlinked" || path == "/api/ui/v1/tokens" { + return true + } + if path == "/api/ui/v1/audit/events" || path == "/api/ui/v1/audit/export" { + return true + } + if strings.HasPrefix(path, "/api/ui/v1/projects/") { + segments := strings.Split(strings.TrimPrefix(path, "/api/ui/v1/projects/"), "/") + return (len(segments) == 1 && segments[0] != "") || + (len(segments) == 2 && segments[0] != "" && segments[1] == "yaml") + } + const sandboxPrefix = "/api/ui/v1/sandboxes/" + if strings.HasPrefix(path, sandboxPrefix) { + segments := strings.Split(strings.TrimPrefix(path, sandboxPrefix), "/") + return len(segments) >= 2 && segments[0] != "" && segments[1] == "agent-records" + } + return false +} + +func isReadOnlyRESTPath(path string) bool { + if path == "/api/version" || path == "/api/webhook-sources" || path == "/api/events" || path == "/api/events/topics" { + return true + } + if strings.HasPrefix(path, "/api/events/") { + segments := strings.Split(strings.TrimPrefix(path, "/api/events/"), "/") + return (len(segments) == 1 && segments[0] != "") || + (len(segments) == 2 && segments[0] != "" && (segments[1] == "sessions" || segments[1] == "sandboxes" || segments[1] == "runs" || segments[1] == "trace")) + } + const workspacePrefix = "/api/agent-compose/workspaces/" + if strings.HasPrefix(path, workspacePrefix) { + segments := strings.Split(strings.TrimPrefix(path, workspacePrefix), "/") + return len(segments) == 2 && segments[0] != "" && (segments[1] == "files" || segments[1] == "download") + } + return false +} diff --git a/src/AppShell.svelte b/src/AppShell.svelte index 5e3a48e..f576dc1 100644 --- a/src/AppShell.svelte +++ b/src/AppShell.svelte @@ -60,7 +60,8 @@ const path = router.path.replace(/\/+$/, '') || '/'; let target = ''; if (path === '/ui' || path === '/workbench') target = '/'; - else if (path === '/automation-tasks') target = '/automations'; + else if (path === '/automation-tasks' || path.startsWith('/automations')) target = '/projects'; + else if (path.startsWith('/automation-runs')) target = '/projects'; else if (path === '/agents') target = '/projects'; else if (path.startsWith('/debug/runs/')) target = `/runs/${path.slice('/debug/runs/'.length)}/terminal`; if (target) router.replace(target); @@ -118,6 +119,11 @@ const runDetailId = $derived(matchDetail('/runs', p)); const sandboxDetailId = $derived(matchDetail('/sandboxes', p)); + function projectSubroute(path: string, segment: 'automations' | 'automation-runs'): boolean { + const parts = path.split('/').filter(Boolean); + return parts[0] === 'projects' && Boolean(parts[1]) && parts[2] === segment; + } + $effect(() => { void p; mobileNavigationOpen = false; @@ -190,25 +196,25 @@ class="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom)]" > {#if p === '/'} - - {:else if p.startsWith('/projects') || p.startsWith('/agents')} - - {:else if p.startsWith('/automation-runs/')} + + {:else if projectSubroute(p, 'automation-runs')} - {:else if p.startsWith('/automations')} + {:else if projectSubroute(p, 'automations')} + {:else if p.startsWith('/projects') || p.startsWith('/agents')} + {:else if p.startsWith('/events/')} - + {:else if p.startsWith('/events')} {:else if sandboxDetailId} - + {:else if p.startsWith('/sandboxes')} {:else if p === '/runs/unlinked'} {:else if runDetailId} - + {:else if p.startsWith('/settings/caches')} {:else if p.startsWith('/audit')} @@ -226,7 +232,7 @@ {:else if p.startsWith('/skills')} {:else} - + {/if} diff --git a/src/api/agents.ts b/src/api/agents.ts index a16221a..13dbc51 100644 --- a/src/api/agents.ts +++ b/src/api/agents.ts @@ -60,20 +60,34 @@ export async function listAgentDefinitions(query = ''): Promise 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); - } - } + result.push(...agentDefinitionsFromProject(project, presets, query)); } return result; } +export async function listProjectAgentDefinitions(projectId: string, query = ''): Promise { + const [response, presets] = await Promise.all([ + projectClient.getProject({ project: projectById(projectId), includeSpec: true }), + listWorkspacePresets(), + ]); + if (!response.project) throw new Error('项目不存在'); + return agentDefinitionsFromProject(response.project, presets, query); +} + +function agentDefinitionsFromProject(project: Project, presets: WorkspacePreset[], query: string): AgentDefinition[] { + const normalizedQuery = query.trim().toLowerCase(); + return project.agents + .map((agent) => { + const spec = project.spec?.agents.find((value) => value.name === agent.agentName); + return agentFromV2(project, agent, spec, presets); + }) + .filter( + (agent) => + !normalizedQuery || + `${agent.name} ${agent.agentName} ${agent.description}`.toLowerCase().includes(normalizedQuery), + ); +} + async function listProjects() { const result = []; let offset = 0; diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts index f18c878..48c9ed6 100644 --- a/src/api/dashboard.ts +++ b/src/api/dashboard.ts @@ -1,39 +1,159 @@ -import { dashboardClient } from './client'; +import { RunSource, type RunSummary } from '../gen/agentcompose/v2/agentcompose_pb.js'; +import { timestampToISOString } from '../model/timestamps'; +import { listRecentProjectAutomationRuns, type AutomationRun, type AutomationTask } from './loaders'; +import { listRuns, runStatusName } from './runs'; + +const ACTIVITY_LIMIT = 20; +const SOURCE_LIMIT = 60; + +export type DashboardActivity = { + id: string; + name: string; + status: string; + triggerKind: string; + triggerSource: string; + startedAt: string; + updatedAt: string; + durationMs: number; + projectRunId: string; + schedulerRunId: string; + schedulerId: string; + sandboxId: string; + projectId: string; + agentName: string; +}; export type DashboardOverview = { runningCount: number; recentCount: number; attentionCount: number; updatedAt: string; + activities: DashboardActivity[]; }; -export async function getDashboardOverview(signal?: AbortSignal): Promise { - const resp = await dashboardClient.getDashboardOverview({}, { signal }); - return toDashboardOverview(resp.overview); +export async function getDashboardOverview(): Promise { + const [projectRuns, automation] = await Promise.all([ + listRuns({ limit: SOURCE_LIMIT }), + listRecentProjectAutomationRuns(ACTIVITY_LIMIT), + ]); + return buildDashboardOverview(projectRuns, automation.runs, automation.tasks); } -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 buildDashboardOverview( + projectRuns: RunSummary[], + schedulerRuns: AutomationRun[], + tasks: AutomationTask[], +): DashboardOverview { + const tasksById = new Map(tasks.map((task) => [task.id, task])); + const representedSchedulerRuns = new Set(); + const projectActivities = projectRuns.map((run) => { + const linkedSchedulerRun = findLinkedSchedulerRun(run, schedulerRuns, tasksById); + if (linkedSchedulerRun) representedSchedulerRuns.add(linkedSchedulerRun.id); + return projectActivity(run, linkedSchedulerRun); + }); + const schedulerActivities = schedulerRuns + .filter((run) => !representedSchedulerRuns.has(run.id)) + .map((run) => schedulerActivity(run, tasksById.get(run.loaderId))); + const allActivities = [...projectActivities, ...schedulerActivities].sort( + (left, right) => activityTimestamp(right) - activityTimestamp(left), + ); + + return { + runningCount: allActivities.filter((activity) => isRunning(activity.status)).length, + recentCount: allActivities.length, + attentionCount: allActivities.filter((activity) => needsAttention(activity.status)).length, + updatedAt: new Date().toISOString(), + activities: allActivities.slice(0, ACTIVITY_LIMIT), + }; } -function toDashboardOverview(overview?: { - runs?: { runningCount?: number; recentCount?: number; attentionCount?: number }; - updatedAt?: { seconds?: bigint; nanos?: number }; -}): DashboardOverview { +function projectActivity(run: RunSummary, linkedSchedulerRun?: AutomationRun): DashboardActivity { + const startedAt = timestampToISOString(run.startedAt || run.createdAt); 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() - : '', + id: `project:${run.runId}`, + name: displayName(run.projectName, run.agentName), + status: runStatusName(run.status), + triggerKind: linkedSchedulerRun?.triggerKind ?? '', + triggerSource: linkedSchedulerRun?.triggerSource ?? runSource(run.source), + startedAt, + updatedAt: timestampToISOString(run.updatedAt || run.completedAt || run.startedAt || run.createdAt), + durationMs: Number(run.durationMs), + projectRunId: run.runId, + schedulerRunId: '', + schedulerId: run.schedulerId, + sandboxId: run.sandboxId, + projectId: run.projectId, + agentName: run.agentName, }; } + +function schedulerActivity(run: AutomationRun, task?: AutomationTask): DashboardActivity { + return { + id: `scheduler:${run.id}`, + name: displayName(task?.name ?? '', task?.agentName ?? ''), + status: run.status, + triggerKind: run.triggerKind, + triggerSource: run.triggerSource, + startedAt: run.startedAt, + updatedAt: run.completedAt || run.startedAt, + durationMs: run.durationMs, + projectRunId: '', + schedulerRunId: run.id, + schedulerId: run.loaderId, + sandboxId: '', + projectId: task?.projectId ?? '', + agentName: task?.agentName ?? '', + }; +} + +function findLinkedSchedulerRun( + projectRun: RunSummary, + schedulerRuns: AutomationRun[], + tasksById: Map, +): AutomationRun | undefined { + if (projectRun.source !== RunSource.SCHEDULER || !projectRun.schedulerId) return undefined; + const projectStartedAt = timestampValue(timestampToISOString(projectRun.startedAt || projectRun.createdAt)); + return schedulerRuns.find((schedulerRun) => { + if (schedulerRun.loaderId !== projectRun.schedulerId) return false; + if (projectRun.triggerId && schedulerRun.triggerId && projectRun.triggerId !== schedulerRun.triggerId) return false; + const task = tasksById.get(schedulerRun.loaderId); + if (task && (task.projectId !== projectRun.projectId || task.agentName !== projectRun.agentName)) return false; + if (!projectStartedAt) return true; + const schedulerStartedAt = timestampValue(schedulerRun.startedAt); + const schedulerCompletedAt = timestampValue(schedulerRun.completedAt) || Date.now(); + return schedulerStartedAt <= projectStartedAt + 5_000 && projectStartedAt <= schedulerCompletedAt + 60_000; + }); +} + +function displayName(owner: string, agent: string): string { + const normalizedOwner = owner.trim(); + const normalizedAgent = agent.trim(); + if (normalizedOwner && normalizedAgent && normalizedOwner !== normalizedAgent) { + return `${normalizedOwner} / ${normalizedAgent}`; + } + return normalizedOwner || normalizedAgent; +} + +function runSource(source: RunSource): string { + if (source === RunSource.MANUAL) return 'manual'; + if (source === RunSource.API) return 'api'; + if (source === RunSource.SCHEDULER) return 'scheduler'; + return ''; +} + +function activityTimestamp(activity: DashboardActivity): number { + return timestampValue(activity.updatedAt || activity.startedAt); +} + +function timestampValue(value: string): number { + const timestamp = value ? new Date(value).getTime() : 0; + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function isRunning(status: string): boolean { + return ['running', 'pending', 'active', 'processing'].includes(status.trim().toLowerCase()); +} + +function needsAttention(status: string): boolean { + return ['failed', 'error', 'skipped', 'stopped', 'canceled', 'cancelled'].includes(status.trim().toLowerCase()); +} diff --git a/src/api/loaders.ts b/src/api/loaders.ts index 184f655..4e4bfc9 100644 --- a/src/api/loaders.ts +++ b/src/api/loaders.ts @@ -7,9 +7,9 @@ import { SchedulerRunStatus, TriggerSpec, type Project, + type ProjectScheduler, type ResolvedTrigger, type SchedulerRun, - type SchedulerSummary, } from '../gen/agentcompose/v2/agentcompose_pb.js'; import { toLegacySessionPolicy, toProjectSandboxPolicy, type LegacySessionPolicy } from '../model/sandbox-policy'; import { timestampToISOString as timestampString } from '../model/timestamps'; @@ -219,19 +219,79 @@ export type TopicEventTrace = { descendantsTruncated: boolean; }; -export async function listAutomationTasks(): Promise { - return (await listAllSchedulers()).map(taskFromV2); +export async function listAutomationTasks(projectId: string): Promise { + const project = await loadProject(projectId); + return Promise.all( + project.schedulers.map(async (scheduler) => { + const response = await projectClient.listSchedulerRuns({ + project: projectById(projectId), + agentName: scheduler.agentName, + limit: 1, + }); + return taskFromProjectScheduler(scheduler, response.total, response.runs[0]); + }), + ); +} + +export async function listRecentProjectAutomationRuns( + limit = 20, +): Promise<{ runs: AutomationRun[]; tasks: AutomationTask[] }> { + if (limit <= 0) return { runs: [], tasks: [] }; + + const projects: Project[] = []; + let offset = 0; + for (;;) { + const page = await projectClient.listProjects({ limit: 200, offset }); + const responses = await Promise.all( + page.projects.map((summary) => + projectClient.getProject({ project: projectById(summary.projectId), includeSpec: false }), + ), + ); + projects.push(...responses.flatMap((response) => (response.project ? [response.project] : []))); + const next = nextPageOffset(offset, page.projects.length, page.total); + if (next === undefined) break; + offset = next; + } + + const targets = projects.flatMap((project) => + project.schedulers.map((scheduler) => ({ + projectId: scheduler.projectId || project.summary?.projectId || '', + scheduler, + })), + ); + const responses = await Promise.all( + targets.map(({ projectId, scheduler }) => + projectClient.listSchedulerRuns({ + project: projectById(projectId), + agentName: scheduler.agentName, + limit: Math.min(Math.ceil(limit), 500), + }), + ), + ); + const tasks = targets.map(({ scheduler }, index) => + taskFromProjectScheduler(scheduler, responses[index].total, responses[index].runs[0]), + ); + const uniqueRuns = new Map(); + for (const response of responses) { + for (const run of response.runs) uniqueRuns.set(run.runId, run); + } + return { + runs: [...uniqueRuns.values()] + .map(runFromScheduler) + .sort((left, right) => compareDateDesc(left.startedAt, right.startedAt)) + .slice(0, limit), + tasks, + }; } -export async function getAutomationTask(id: string): Promise { - const found = await findScheduler(id); - if (!found) throw new Error('自动化任务不存在'); +export async function getAutomationTask(projectId: string, agentName: string): Promise { const [response, project] = await Promise.all([ - projectClient.getScheduler({ project: projectById(found.projectId), agentName: found.agentName }), - loadProject(found.projectId), + projectClient.getScheduler({ project: projectById(projectId), agentName }), + loadProject(projectId), ]); - const agent = project.spec?.agents.find((item) => item.name === found.agentName); - const summary = taskFromV2(found); + if (!response.scheduler) throw new Error('自动化任务不存在'); + const agent = project.spec?.agents.find((item) => item.name === agentName); + const summary = taskFromProjectScheduler(response.scheduler); return { ...summary, name: response.spec?.displayName.trim() || response.scheduler?.displayName.trim() || summary.name, @@ -250,22 +310,17 @@ export async function getAutomationTask(id: string): Promise { - const found = await findScheduler(id); - return found ? { projectId: found.projectId, agentName: found.agentName } : undefined; -} - -export async function previewAutomationTask(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 getProjectView(target.projectId); +export async function previewAutomationTask( + projectId: string, + agentName: string, + input: SaveAutomationTaskInput, +): Promise { + const project = await getProjectView(projectId); return previewProjectMutation({ kind: input.id ? 'update_scheduler' : 'create_scheduler', - projectId: target.projectId, + projectId, baseSpecHash: project.specHash, - agentName: target.agentName, + agentName, agent: { env: (input.envItems ?? []) .map((item) => ({ name: item.name.trim(), value: item.value, secret: item.secret })) @@ -275,15 +330,16 @@ export async function previewAutomationTask(input: SaveAutomationTaskInput): Pro }); } -export async function previewDeleteAutomationTask(id: string): Promise { - const found = await findScheduler(id); - if (!found) throw new Error('自动化任务不存在'); - const project = await getProjectView(found.projectId); +export async function previewDeleteAutomationTask( + projectId: string, + agentName: string, +): Promise { + const project = await getProjectView(projectId); return previewProjectMutation({ kind: 'delete_scheduler', - projectId: found.projectId, + projectId, baseSpecHash: project.specHash, - agentName: found.agentName, + agentName, }); } @@ -301,31 +357,33 @@ function schedulerSpecFromInput(input: SaveAutomationTaskInput): SchedulerEditab }; } -export async function setAutomationTaskEnabled(id: string, enabled: boolean): Promise { - const found = await findScheduler(id); - if (!found) throw new Error('自动化任务不存在'); - await projectClient.setSchedulerEnabled({ - project: projectById(found.projectId), - agentName: found.agentName, +export async function setAutomationTaskEnabled( + projectId: string, + agentName: string, + enabled: boolean, +): Promise { + const response = await projectClient.setSchedulerEnabled({ + project: projectById(projectId), + agentName, enabled, }); - return { ...taskFromV2(found), enabled }; + if (!response.scheduler) throw new Error('自动化任务不存在'); + return taskFromProjectScheduler(response.scheduler); } export async function setAutomationTriggerEnabled( - loaderId: string, + projectId: string, + agentName: string, triggerId: string, enabled: boolean, ): Promise { - const found = await findScheduler(loaderId); - if (!found) throw new Error('自动化任务不存在'); await projectClient.setSchedulerTriggerEnabled({ - project: projectById(found.projectId), - agentName: found.agentName, + project: projectById(projectId), + agentName, triggerId, enabled, }); - return getAutomationTask(loaderId); + return getAutomationTask(projectId, agentName); } export async function validateAutomationTask(script: string, runtime: string): Promise { @@ -364,16 +422,17 @@ export async function validateAutomationTask(script: string, runtime: string): P } export async function runAutomationTaskNow( - loaderId: string, + projectId: string, + agentName: string, payloadJson: string, triggerId = '', ): Promise { - const scheduler = await requireScheduler(loaderId); - const effectiveTriggerId = triggerId || (await getAutomationTask(loaderId)).triggers[0]?.triggerId; + const task = await getAutomationTask(projectId, agentName); + const effectiveTriggerId = triggerId || task.triggers[0]?.triggerId; if (!effectiveTriggerId) throw new Error('自动化没有可用的触发条件'); const response = await projectClient.runScheduler({ - project: projectById(scheduler.projectId), - agentName: scheduler.agentName, + project: projectById(projectId), + agentName, triggerId: effectiveTriggerId, payloadJson, }); @@ -383,59 +442,19 @@ export async function runAutomationTaskNow( return result; } -export async function getAutomationRun(loaderId: string, runId: string): Promise { - const scheduler = await requireScheduler(loaderId); - const response = await projectClient.getSchedulerRun({ project: projectById(scheduler.projectId), runId }); +export async function getAutomationRun(projectId: string, runId: string): Promise { + const response = await projectClient.getSchedulerRun({ project: projectById(projectId), runId }); if (!response.run) throw new Error('自动化运行不存在'); return runFromScheduler(response.run); } -export async function getAutomationRunById(runId: string): Promise { - const projectIds = [...new Set((await listAllSchedulers()).map((item) => item.projectId))]; - for (const projectId of projectIds) { - try { - const response = await projectClient.getSchedulerRun({ project: projectById(projectId), runId }); - if (response.run) return runFromScheduler(response.run); - } catch { - // Scheduler run IDs are global, but GetSchedulerRun requires its project. - } - } - throw new Error('调度运行不存在'); -} - -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: projectById(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, + projectId: string, runId: string, reason = '', ): Promise<{ run: AutomationRun; stopRequested: boolean }> { - const scheduler = await requireScheduler(loaderId); const response = await projectClient.stopSchedulerRun({ - project: projectById(scheduler.projectId), + project: projectById(projectId), runId, reason, }); @@ -443,17 +462,19 @@ export async function stopAutomationRun( 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 []; +export async function listAutomationEvents( + projectId: string, + agentName: string, + limit = 50, +): Promise { const response = await projectClient.listSchedulerEvents({ - project: projectById(found.projectId), - agentName: found.agentName, + project: projectById(projectId), + agentName, limit, }); return response.events.map((item) => ({ id: item.id, - loaderId, + loaderId: item.schedulerId, runId: item.runId, triggerId: item.triggerId, type: item.type, @@ -468,45 +489,12 @@ export async function listAutomationEvents(loaderId: string, limit = 50): Promis })); } -async function listAllSchedulers(): Promise { - const result: SchedulerSummary[] = []; - let offset = 0; - for (;;) { - const response = await projectClient.listSchedulers({ limit: 500, offset }); - result.push(...response.schedulers); - const next = nextPageOffset(offset, response.schedulers.length, response.total); - if (next === undefined) return result; - offset = next; - } -} -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 loadProject(projectId: string): Promise { const response = await projectClient.getProject({ project: projectById(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 }; - } - const next = nextPageOffset(offset, listed.projects.length, listed.total); - if (next === undefined) return undefined; - offset = next; - } -} -function taskFromV2(item: SchedulerSummary): AutomationTask { +function taskFromProjectScheduler(item: ProjectScheduler, runCount = 0, latestRun?: SchedulerRun): AutomationTask { return { id: item.schedulerId, projectId: item.projectId, @@ -520,10 +508,10 @@ function taskFromV2(item: SchedulerSummary): AutomationTask { capsetIds: [], defaultAgent: '', triggerCount: item.triggerCount, - runCount: item.runCount, + runCount, eventCount: 0, - latestRunAt: timestampString(item.latestRunAt), - lastError: item.lastError, + latestRunAt: timestampString(latestRun?.startedAt), + lastError: latestRun?.error ?? '', createdAt: '', updatedAt: '', driver: '', diff --git a/src/components/AutomationCollection.svelte b/src/components/AutomationCollection.svelte index ff92aef..86ba1bf 100644 --- a/src/components/AutomationCollection.svelte +++ b/src/components/AutomationCollection.svelte @@ -16,7 +16,7 @@ let { tasks, agents, - projects, + project, loading, onRun, onEdit, @@ -25,7 +25,7 @@ }: { tasks: AutomationTask[]; agents: AgentDefinition[]; - projects: ProjectView[]; + project: ProjectView | null; loading: boolean; onRun: (task: AutomationTask) => void; onEdit: (task: AutomationTask) => void; @@ -36,39 +36,30 @@ const params = new URLSearchParams(window.location.search); let query = $state(params.get('agent') ?? ''); let statusFilter = $state<'all' | 'enabled' | 'disabled'>('enabled'); - let projectFilter = $state(params.get('project') ?? 'all'); const enabledCount = $derived(tasks.filter((task) => task.enabled).length); const disabledCount = $derived(tasks.length - enabledCount); - const projectOptions = $derived( - projects - .map((project) => [project.projectId, project.name] as const) - .sort((left, right) => left[1].localeCompare(right[1], 'zh-CN')), - ); - const visibleTasks = $derived(filterTasks(tasks, query, statusFilter, projectFilter)); + const visibleTasks = $derived(filterTasks(tasks, query, statusFilter)); function agentForTask(task: AutomationTask): AgentDefinition | undefined { return agents.find((agent) => agent.projectId === task.projectId && agent.agentName === task.agentName); } function taskEditable(task: AutomationTask): boolean { - return projects.find((project) => project.projectId === task.projectId)?.editable ?? false; + return project?.projectId === task.projectId && project.editable; } function filterTasks( items: AutomationTask[], value: string, status: 'all' | 'enabled' | 'disabled', - projectId: string, ): AutomationTask[] { const normalized = value.trim().toLowerCase(); return items .filter((task) => status === 'all' || task.enabled === (status === 'enabled')) - .filter((task) => projectId === 'all' || task.projectId === projectId) .filter((task) => { - const agent = agentForTask(task); return normalized - ? `${task.name} ${task.description} ${agent?.projectName ?? ''} ${task.agentName} ${task.id}` + ? `${task.name} ${task.description} ${project?.name ?? ''} ${task.agentName} ${task.id}` .toLowerCase() .includes(normalized) : true; @@ -76,8 +67,8 @@ .sort( (left, right) => Number(right.enabled) - Number(left.enabled) || - (agentForTask(left)?.projectName ?? left.projectId).localeCompare( - agentForTask(right)?.projectName ?? right.projectId, + (agentForTask(left)?.name ?? left.agentName).localeCompare( + agentForTask(right)?.name ?? right.agentName, 'zh-CN', ) || left.name.localeCompare(right.name, 'zh-CN'), @@ -88,14 +79,6 @@ - - {t('全部项目')} - {#each projectOptions as option (option[0])}{option[1]}{/each} - {#each [{ value: 'enabled', label: `${t('已启用')} ${enabledCount}` }, { value: 'disabled', label: `${t('已停用')} ${disabledCount}` }, { value: 'all', label: `${t('全部')} ${tasks.length}` }] as option (option.value)} navigate(`/projects/${task.projectId}/agents/${encodeURIComponent(task.agentName)}`)} + onclick={() => + navigate( + `/projects/${encodeURIComponent(task.projectId)}/agents/${encodeURIComponent(task.agentName)}`, + )} > - {taskAgent?.projectName || task.projectId.slice(0, 8)} + {project?.name || task.projectId.slice(0, 8)} {taskAgent?.name || task.agentName} @@ -197,7 +183,7 @@ - {taskAgent?.projectName || task.projectId.slice(0, 8)} / {taskAgent?.name || task.agentName} + {project?.name || task.projectId.slice(0, 8)} / {taskAgent?.name || task.agentName} {task.name} diff --git a/src/lib/assets/agent-compose-login-lockup.png b/src/lib/assets/agent-compose-login-lockup.png new file mode 100644 index 0000000..2ceeaa3 Binary files /dev/null and b/src/lib/assets/agent-compose-login-lockup.png differ diff --git a/src/lib/assets/agent-compose-sidebar.png b/src/lib/assets/agent-compose-sidebar.png new file mode 100644 index 0000000..e07d33b Binary files /dev/null and b/src/lib/assets/agent-compose-sidebar.png differ diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index 073d446..92e060e 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -3,7 +3,7 @@ import { navGroups } from '$lib/nav'; import { t } from '$lib/i18n.svelte'; import { router, navigate } from '$lib/router.svelte'; - import BrandLogo from './brand-logo.svelte'; + import sidebarBrand from '$lib/assets/agent-compose-sidebar.png'; let { collapsed = false, @@ -28,13 +28,10 @@ > - {#if collapsed} - - - - {:else} - - + + {#if !collapsed} + + Agent-Compose {/if} diff --git a/src/lib/components/brand-mark.svelte b/src/lib/components/brand-mark.svelte new file mode 100644 index 0000000..5351f37 --- /dev/null +++ b/src/lib/components/brand-mark.svelte @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + diff --git a/src/lib/components/sandbox-workbench.svelte b/src/lib/components/sandbox-workbench.svelte index c4f208d..cf0a23a 100644 --- a/src/lib/components/sandbox-workbench.svelte +++ b/src/lib/components/sandbox-workbench.svelte @@ -45,11 +45,13 @@ initialTab, embedded = false, contextLog = '', + canWrite = true, }: { sandboxId: string; initialTab?: 'conversation' | 'logs' | 'records' | 'terminal'; embedded?: boolean; contextLog?: string; + canWrite?: boolean; } = $props(); let sandbox = $state(null); @@ -101,11 +103,13 @@ const selectedTarget = $derived( targets.find((item) => targetKey(item) === selectedTargetKey) ?? targets[0] ?? target, ); - const jupyterHref = $derived(jupyterEntryHref(sandbox)); + const jupyterHref = $derived(canWrite ? jupyterEntryHref(sandbox) : ''); const normalizedStatus = $derived(sandbox?.status.trim().toLowerCase() ?? ''); const runnable = $derived(normalizedStatus === 'running'); const resumable = $derived(normalizedStatus === 'stopped'); - const terminalAvailable = $derived(normalizedStatus !== 'failed' && normalizedStatus !== 'deleting'); + const terminalAvailable = $derived( + canWrite && normalizedStatus !== 'failed' && normalizedStatus !== 'deleting', + ); const stoppedRuntime = $derived(sandbox ? stoppedRuntimePresentation(sandbox) : null); onMount(() => () => { @@ -145,7 +149,7 @@ }); $effect(() => { - const key = tab === 'terminal' && runnable && sandbox ? sandboxId : ''; + const key = canWrite && tab === 'terminal' && runnable && sandbox ? sandboxId : ''; if (key && key !== terminalAutoKey) { terminalAutoKey = key; terminalAutoAttempts = 0; @@ -184,7 +188,7 @@ tab = initialTab === 'records' ? 'records' - : initialTab === 'terminal' + : canWrite && initialTab === 'terminal' ? 'terminal' : initialTab === 'conversation' && conversationAvailable ? 'conversation' @@ -315,7 +319,7 @@ } function connectTerminal(automatic = false): void { - if (!sandbox || terminal) return; + if (!canWrite || !sandbox || terminal) return; window.clearTimeout(terminalRetryTimer); if (automatic) terminalAutoAttempts += 1; terminalError = ''; diff --git a/src/lib/i18n.svelte.ts b/src/lib/i18n.svelte.ts index ecb045e..e8fe621 100644 --- a/src/lib/i18n.svelte.ts +++ b/src/lib/i18n.svelte.ts @@ -177,7 +177,18 @@ const english: Record = { 回复失败: 'Response failed', 刚刚: 'just now', 运行状态与近期结果: 'Run status and recent results', + 工作区运行态势与最新执行活动: 'Workspace runtime posture and latest activity', 概览加载失败: 'Failed to load overview', + 需要关注: 'Needs attention', + 近期活动: 'Recent activity', + 服务健康: 'Service health', + 连接异常: 'Connection issue', + 最近执行: 'Recent executions', + 项目运行与自动化运行的最新记录: 'Latest project and automation runs', + 未命名执行: 'Unnamed execution', + 未知来源: 'Unknown source', + 超时: 'Timeout', + 暂无执行记录: 'No execution records yet', 运行失败: 'Run failed', 近期失败: 'Recent failures', 近期运行: 'Recent runs', diff --git a/src/lib/nav.ts b/src/lib/nav.ts index 310ef5b..0a14ae4 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -2,7 +2,6 @@ import type { Component } from 'svelte'; import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; import FolderKanban from '@lucide/svelte/icons/folder-kanban'; -import CalendarClock from '@lucide/svelte/icons/calendar-clock'; import Activity from '@lucide/svelte/icons/activity'; import Webhook from '@lucide/svelte/icons/webhook'; import Box from '@lucide/svelte/icons/box'; @@ -41,12 +40,6 @@ const navGroupDefinitions: NavGroup[] = [ icon: FolderKanban, match: (path) => startsWith('/projects')(path) || startsWith('/agents')(path), }, - { - label: '自动化', - href: '/automations', - icon: CalendarClock, - match: (path) => startsWith('/automations')(path) || startsWith('/automation-runs')(path), - }, { label: '运行记录', href: '/sandboxes', diff --git a/src/routes/AutomationRunDetail.svelte b/src/routes/AutomationRunDetail.svelte index 2511df1..4593295 100644 --- a/src/routes/AutomationRunDetail.svelte +++ b/src/routes/AutomationRunDetail.svelte @@ -8,11 +8,13 @@ import TechnicalDetails from '$lib/components/technical-details.svelte'; import Timestamp from '$lib/components/timestamp.svelte'; import { router } from '$lib/router.svelte'; - import { getAutomationRunById, type AutomationRun } from '../api/loaders'; + import { getAutomationRun, type AutomationRun } from '../api/loaders'; import { parseAutomationResult } from '../model/automation-result'; import { triggerKindLabel } from '../model/presentation'; - const runId = $derived(decodeURIComponent(router.path.split('/')[2] || '')); + const parts = $derived(router.path.split('/').filter(Boolean)); + const projectId = $derived(decodeURIComponent(parts[1] || '')); + const runId = $derived(decodeURIComponent(parts[3] || '')); let run = $state(null); let loading = $state(true); let error = $state(''); @@ -20,7 +22,7 @@ onMount(async () => { try { - run = await getAutomationRunById(runId); + run = await getAutomationRun(projectId, runId); } catch (cause) { error = cause instanceof Error ? cause.message : '自动化运行加载失败'; } finally { diff --git a/src/routes/Automations.svelte b/src/routes/Automations.svelte index a349d68..93e401e 100644 --- a/src/routes/Automations.svelte +++ b/src/routes/Automations.svelte @@ -12,7 +12,7 @@ import TechnicalDetails from '$lib/components/technical-details.svelte'; import { preloadMonaco } from '$lib/monaco'; import { navigate, router } from '$lib/router.svelte'; - import { listAgentDefinitions, type AgentDefinition } from '../api/agents'; + import { listProjectAgentDefinitions, type AgentDefinition } from '../api/agents'; import { getAutomationTask, listAutomationTasks, @@ -27,7 +27,7 @@ } from '../api/loaders'; import { applyProjectPreview, - listProjectViews, + getProjectView, type ProjectDeploymentPreview, type ProjectView, } from '../api/projects'; @@ -61,7 +61,7 @@ let tasks = $state([]); let agents = $state([]); - let projects = $state([]); + let project = $state(null); let draft = $state(emptyDraft()); let loading = $state(true); let saving = $state(false); @@ -82,37 +82,34 @@ const MIN_EDITOR_PANE_WIDTH = 420; const parts = $derived(router.path.split('/').filter(Boolean)); - const taskId = $derived(parts[1] && parts[1] !== 'new' ? decodeURIComponent(parts[1]) : ''); - const editing = $derived(parts[1] === 'new' || parts[2] === 'edit'); - const creating = $derived(parts[1] === 'new'); + const projectId = $derived(parts[0] === 'projects' && parts[1] ? decodeURIComponent(parts[1]) : ''); + const routeAgentName = $derived( + parts[2] === 'automations' && parts[3] && parts[3] !== 'new' ? decodeURIComponent(parts[3]) : '', + ); + const editing = $derived(parts[2] === 'automations' && (parts[3] === 'new' || parts[4] === 'edit')); + const creating = $derived(parts[2] === 'automations' && parts[3] === 'new'); + const projectPath = $derived(`/projects/${encodeURIComponent(projectId)}`); + const listPath = $derived(`${projectPath}/automations`); const draftDirty = $derived(editing && draftSignature() !== draftBaseline); const presentedChanges = $derived(preview ? presentProjectChanges(preview.changes) : []); const availableAgents = $derived( agents.filter( - (agent) => - (taskId || !tasks.some((task) => task.projectId === agent.projectId && task.agentName === agent.agentName)) && - projects.find((project) => project.projectId === agent.projectId)?.editable, + (agent) => (!creating || !tasks.some((task) => task.agentName === agent.agentName)) && project?.editable, ), ); const selectedDraftAgent = $derived( - agents.find( - (agent) => - agent.id === draft.agentId || - (agent.agentName === draft.agentId && - (!taskId || agent.projectId === tasks.find((task) => task.id === taskId)?.projectId)), - ), + agents.find((agent) => agent.id === draft.agentId || agent.agentName === draft.agentId), ); onMount(async () => { await load(); const params = new URLSearchParams(window.location.search); - const contextProject = params.get('project'); const contextAgent = params.get('agent'); - if (creating && contextProject && contextAgent) { - const agent = agents.find((item) => item.projectId === contextProject && item.agentName === contextAgent); + if (creating && contextAgent) { + const agent = agents.find((item) => item.agentName === contextAgent); if (agent) draft.agentId = agent.id; - } else if (editing && taskId) { - await loadDraft(taskId); + } else if (editing && routeAgentName) { + await loadDraft(routeAgentName); } }); @@ -144,10 +141,11 @@ loading = true; error = ''; try { - [tasks, agents, projects] = await Promise.all([ - listAutomationTasks(), - listAgentDefinitions(), - listProjectViews(), + if (!projectId) throw new Error(t('项目不存在')); + [tasks, agents, project] = await Promise.all([ + listAutomationTasks(projectId), + listProjectAgentDefinitions(projectId), + getProjectView(projectId), ]); } catch (cause) { error = errorMessage(cause); @@ -156,25 +154,25 @@ } } - async function loadDraft(id: string): Promise { + async function loadDraft(agentName: string): Promise { try { - draft = draftFromDetail(await getAutomationTask(id)); + draft = draftFromDetail(await getAutomationTask(projectId, agentName)); draftBaseline = draftSignature(); } catch (cause) { error = errorMessage(cause); } } - async function beginEdit(id: string): Promise { - await loadDraft(id); - navigate(`/automations/${encodeURIComponent(id)}/edit`); + async function beginEdit(task: AutomationTask): Promise { + await loadDraft(task.agentName); + navigate(`${listPath}/${encodeURIComponent(task.agentName)}/edit`); } function beginCreate(): void { draft = emptyDraft(); draftBaseline = draftSignature(); validation = null; - navigate('/automations/new'); + navigate(`${listPath}/new`); } async function validate(): Promise { @@ -199,6 +197,10 @@ error = t('请选择绑定智能体'); return; } + if (!selectedDraftAgent) { + error = t('请选择绑定智能体'); + return; + } const environmentError = validateAgentEnvironment(draft.envItems ?? []); if (environmentError) { error = t(environmentError); @@ -208,7 +210,10 @@ error = ''; try { if (!(await validate())) return; - preview = await previewAutomationTask({ ...draft, id: creating ? undefined : taskId }); + preview = await previewAutomationTask(projectId, selectedDraftAgent.agentName, { + ...draft, + id: creating ? undefined : draft.id, + }); } catch (cause) { error = errorMessage(cause); } finally { @@ -225,7 +230,7 @@ preview = null; await load(); draftBaseline = draftSignature(); - navigate('/automations'); + navigate(listPath); } catch (cause) { error = errorMessage(cause); } finally { @@ -236,7 +241,7 @@ async function prepareRun(task: AutomationTask): Promise { error = ''; try { - runTask = await getAutomationTask(task.id); + runTask = await getAutomationTask(projectId, task.agentName); runTriggerId = runTask.triggers.find((trigger) => trigger.enabled)?.triggerId ?? ''; runPayload = '{}'; } catch (cause) { @@ -250,9 +255,9 @@ error = ''; try { JSON.parse(runPayload); - const run = await runAutomationTaskNow(runTask.id, runPayload, runTriggerId); + const run = await runAutomationTaskNow(projectId, runTask.agentName, runPayload, runTriggerId); runTask = null; - navigate(`/automation-runs/${encodeURIComponent(run.id)}`); + navigate(`${projectPath}/automation-runs/${encodeURIComponent(run.id)}`); } catch (cause) { error = errorMessage(cause); } finally { @@ -262,7 +267,7 @@ async function toggle(task: AutomationTask): Promise { try { - await setAutomationTaskEnabled(task.id, !task.enabled); + await setAutomationTaskEnabled(projectId, task.agentName, !task.enabled); await load(); } catch (cause) { error = errorMessage(cause); @@ -271,7 +276,7 @@ async function remove(task: AutomationTask): Promise { try { - preview = await previewDeleteAutomationTask(task.id); + preview = await previewDeleteAutomationTask(projectId, task.agentName); } catch (cause) { error = errorMessage(cause); } @@ -354,9 +359,10 @@ data-page-layout={editing ? 'editor' : 'document'} class={editing ? 'flex min-h-full flex-col lg:h-full lg:min-h-0 lg:overflow-hidden' : 'min-h-full'} > - + {#snippet actions()} {#if !editing} + navigate(projectPath)}>{t('返回项目')} {t('配置自动化')} @@ -388,7 +394,7 @@ {t('保存前预览项目变更')} - navigate('/automations')}>{t('取消')} + navigate(listPath)}>{t('取消')} {t('校验')} {t(saving ? '正在生成预览…' : '预览部署')} @@ -413,7 +419,7 @@ > {t('请选择')} {#each availableAgents as agent (agent.id)} - {agent.projectName} / {agent.name} + {agent.name} {/each} @@ -519,10 +525,10 @@ beginEdit(task.id)} + onEdit={beginEdit} onToggle={toggle} onRemove={remove} /> diff --git a/src/routes/EventDetail.svelte b/src/routes/EventDetail.svelte index ce736f5..3db9d5f 100644 --- a/src/routes/EventDetail.svelte +++ b/src/routes/EventDetail.svelte @@ -16,6 +16,8 @@ import { compactIdentifier } from '../model/identifiers'; import { t } from '$lib/i18n.svelte'; + let { canWrite = true }: { canWrite?: boolean } = $props(); + const eventId = $derived(decodeURIComponent(router.path.split('/')[2] || '')); let event = $state(null); let runTraces = $state([]); @@ -155,6 +157,7 @@ {#key selectedSandboxId}{/key} diff --git a/src/routes/Login.svelte b/src/routes/Login.svelte index 96b2e71..5d0732e 100644 --- a/src/routes/Login.svelte +++ b/src/routes/Login.svelte @@ -1,8 +1,8 @@ - - + + {#snippet actions()} - {t('刷新')} + + + {loading ? t('刷新中…') : t('刷新')} + {/snippet} @@ -108,155 +159,98 @@ {#if error}{error}{/if} - navigate('/runs')} - class="flex min-w-0 items-center gap-3 rounded-lg border border-info/30 bg-info/5 px-3 py-2.5 text-left transition-colors hover:bg-info/10" - > + - {t('运行中')}{running.length} - - navigate('/runs')} - class="flex min-w-0 items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-left transition-colors hover:bg-destructive/10" - > + + {t('运行中')} + {overview.runningCount} + + + - {t('近期失败')}{attention.length} - - navigate('/runs')} - class="flex min-w-0 items-center gap-3 rounded-lg border border-border bg-card px-3 py-2.5 text-left transition-colors hover:bg-accent/50" - > + + {t('需要关注')} + {overview.attentionCount} + + + - {t('近期运行')}{recent.length} - - - - {t('服务')} {health.status}{t('正常')} · CPU {health.cpu} · RSS {health.rss} + {t('近期活动')} + {overview.recentCount} + + + + + + {t('服务健康')} + {t(health.status)} · {health.detail} + - - - - - {t('近期结果')} - {t('最近 {count} 条', { count: recentPreview.length })} - - navigate('/runs')} - class="flex items-center gap-0.5 text-xs font-medium text-primary hover:underline" - >{t('全部运行')} - - - - - {#each recentPreview as r (r.fullId)} - navigate(`/runs/${r.fullId}`)} - > - - - - - {r.agent} - - {r.duration} - - - {:else} - {t('暂无近期运行')} - {/each} - - - - - - - - - {t('近期失败')} - {attention.length} - - - {#each attentionPreview as item (item.runId)} - - navigate(`/runs/${item.runId}`)}> - {item.title} - {item.sub} - - navigate(`/runs/${item.runId}/terminal`)} - title={t('进入终端')}> - - {:else} - {t('暂无失败运行')} - {/each} - - - - - - {t('运行中')} - {running.length} - - - {#each runningPreview as item (item.fullId)} - navigate(`/runs/${item.fullId}`)} - class="flex h-10 w-full items-center justify-between gap-2 rounded-md px-2 text-left hover:bg-info/5" + + + + {t('最近执行')} + {t('项目运行与自动化运行的最新记录')} + + + + + + + {t('名称')} + {t('状态')} + {t('触发方式')} + {t('时间')} + {t('耗时')} + + + + {#each overview.activities as activity (activity.id)} + openActivity(activity)} + onkeydown={(event) => onActivityKeydown(event, activity)} > - {item.id}{item.agent} · {item.duration} - - {:else} - {t('当前没有运行中的任务')} - {/each} - - - - + + {activityName(activity)} + + + {triggerLabel(activity)} + + {durationLabel(activity)} + + {:else} + {loading ? t('正在加载…') : t('暂无执行记录')} + {/each} + + + + diff --git a/src/routes/Projects.svelte b/src/routes/Projects.svelte index c400525..f6a1f59 100644 --- a/src/routes/Projects.svelte +++ b/src/routes/Projects.svelte @@ -779,7 +779,7 @@ navigate(`/automations?project=${encodeURIComponent(selectedProject.projectId)}`)} + onclick={() => navigate(`/projects/${encodeURIComponent(selectedProject.projectId)}/automations`)} > {t('自动化任务')} {selectedProject.schedulerCount} @@ -839,8 +839,8 @@ onclick={() => navigate( selectedAgent.hasScheduler - ? `/automations?project=${encodeURIComponent(selectedProject.projectId)}&agent=${encodeURIComponent(selectedAgent.agentName)}` - : `/automations/new?project=${encodeURIComponent(selectedProject.projectId)}&agent=${encodeURIComponent(selectedAgent.agentName)}`, + ? `/projects/${encodeURIComponent(selectedProject.projectId)}/automations?agent=${encodeURIComponent(selectedAgent.agentName)}` + : `/projects/${encodeURIComponent(selectedProject.projectId)}/automations/new?agent=${encodeURIComponent(selectedAgent.agentName)}`, )}>{t(selectedAgent.hasScheduler ? '管理自动化' : '配置自动化')}{/if} {#if selectedProject.editable}(null); let events = $state([]); let sandbox = $state(null); - let tab = $state(router.path.endsWith('/terminal') ? 'terminal' : 'chat'); + let tab = $state<'chat' | 'process' | 'terminal' | 'sandbox'>('chat'); let shellLines = $state([]); let terminalState = $state(t('未连接')); let terminalFontSize = $state(15); @@ -71,7 +73,13 @@ const activeStream = $derived( sandboxStream?.running ? sandboxStream : runStream?.running ? runStream : (sandboxStream ?? runStream), ); - const jupyterHref = $derived(jupyterEntryHref(sandbox)); + const jupyterHref = $derived(canWrite ? jupyterEntryHref(sandbox) : ''); + + $effect(() => { + if (!canWrite && router.path.endsWith('/terminal')) { + navigate(`/runs/${encodeURIComponent(runId)}`); + } + }); $effect(() => { const targetRunId = runId; @@ -102,7 +110,7 @@ const version = ++loadVersion; loading = true; error = ''; - tab = router.path.endsWith('/terminal') ? 'terminal' : 'chat'; + tab = canWrite && router.path.endsWith('/terminal') ? 'terminal' : 'chat'; window.clearTimeout(statusPollTimer); controller?.abort(); controller = new AbortController(); @@ -201,6 +209,8 @@ } function selectTab(value: string): void { + if (value !== 'chat' && value !== 'process' && value !== 'terminal' && value !== 'sandbox') return; + if (value === 'terminal' && !canWrite) return; tab = value; if (value === 'terminal') { navigate(`/runs/${encodeURIComponent(runId)}/terminal`); @@ -212,7 +222,7 @@ } function connectShell(): void { - if (!summary?.sandboxId || terminal) return; + if (!canWrite || !summary?.sandboxId || terminal) return; shellLines = []; error = ''; const connectionVersion = ++terminalConnectionVersion; @@ -389,7 +399,7 @@ >{t('对话')}{t('执行过程')}{t('终端')}{#if canWrite}{t('终端')}{/if}{t('执行环境')} @@ -423,7 +433,7 @@ onDownloadLogs={downloadLogs} /> - {#if terminalExpanded}{/if} terminal?.resize(cols, rows)} /> - + {/if} diff --git a/src/routes/SandboxDetail.svelte b/src/routes/SandboxDetail.svelte index ea9118f..1239884 100644 --- a/src/routes/SandboxDetail.svelte +++ b/src/routes/SandboxDetail.svelte @@ -3,12 +3,14 @@ import SandboxWorkbench from '$lib/components/sandbox-workbench.svelte'; import { router } from '$lib/router.svelte'; + let { canWrite = true }: { canWrite?: boolean } = $props(); + const sandboxId = $derived(decodeURIComponent(router.path.split('/')[2] || '')); 执行环境 - + diff --git a/tests/e2e/sidebar-brand.spec.ts b/tests/e2e/sidebar-brand.spec.ts new file mode 100644 index 0000000..d11291f --- /dev/null +++ b/tests/e2e/sidebar-brand.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; + +test.use({ viewport: { width: 1600, height: 900 } }); + +test('shows the approved image and product name in the sidebar', async ({ page }) => { + await page.route('**/api/auth/status', (route) => + route.fulfill({ + json: { + enabled: false, + loggedIn: false, + oauthEnabled: false, + username: '', + expiresAt: '', + }, + }), + ); + + await page.goto('/'); + + const sidebar = page.locator('aside').first(); + const logo = sidebar.locator('img[alt="Agent-Compose"]'); + await expect(sidebar.getByText('Agent-Compose', { exact: true })).toBeVisible(); + await expect(logo).toBeVisible(); + await expect(logo).toHaveCSS('width', '41.25px'); + await expect(logo).toHaveCSS('height', '30px'); +}); + +test('shows the approved lockup at the reference-aligned size on the login page', async ({ page }) => { + await page.route('**/api/auth/status', (route) => + route.fulfill({ + json: { + enabled: true, + loggedIn: false, + oauthEnabled: false, + username: '', + expiresAt: '', + }, + }), + ); + + await page.goto('/'); + + const logo = page.locator('main img[alt="Agent-Compose"]'); + await expect(logo).toBeVisible(); + await expect(logo).toHaveCSS('height', '41.25px'); + await expect(page.getByRole('heading', { name: 'Agent-Compose', exact: true })).toBeVisible(); + await expect(page.getByText('登录 Web 控制台', { exact: true })).toBeVisible(); +}); diff --git a/tests/e2e/ui.spec.ts b/tests/e2e/ui.spec.ts index 23b1916..91efe98 100644 --- a/tests/e2e/ui.spec.ts +++ b/tests/e2e/ui.spec.ts @@ -62,7 +62,6 @@ scheduler.on('webhook.ui-regression.acceptance', 'ui-webhook-multi-conversation' const routes = [ '/', '/projects', - '/automations', '/sandboxes', '/runs/unlinked', '/events', @@ -349,24 +348,27 @@ async function setMonacoValue(page: Page, value: string): Promise { async function configureWebhookAutomation(page: Page, script: string): Promise { await ensureWebhookAcceptanceAgent(page); - await page.getByRole('button', { name: '自动化', exact: true }).click(); + const projectResponse = await page.request.post('/agentcompose.v2.ProjectService/GetProject', { + data: { project: { name: 'ui-agents' }, includeSpec: true }, + }); + const projectBody = (await projectResponse.json()) as { + project: { + summary: { projectId: string }; + schedulers: Array<{ agentName: string; displayName: string; triggerCount: number }>; + spec: { agents: Array> }; + }; + }; + const scheduler = projectBody.project.schedulers.find((item) => item.displayName === webhookSchedulerDisplayName); + expect(scheduler).toBeTruthy(); + await navigateInApp( + page, + `/projects/${encodeURIComponent(projectBody.project.summary.projectId)}/automations?agent=${encodeURIComponent(scheduler!.agentName)}`, + ); await expect(page.getByRole('heading', { name: '自动化', exact: true })).toBeVisible(); const taskRow = page.locator('tbody tr:visible, article:visible').filter({ hasText: webhookSchedulerDisplayName }); if (!(await taskRow.isVisible())) await page.getByRole('button', { name: /^全部 \d+$/ }).click(); await expect(taskRow).toBeVisible(); - const schedulersResponse = await page.request.post('/agentcompose.v2.ProjectService/ListSchedulers', { data: {} }); - const schedulers = (await schedulersResponse.json()) as { - schedulers: Array<{ projectId: string; agentName: string; displayName: string; triggerCount: number }>; - }; - const scheduler = schedulers.schedulers.find((item) => item.displayName === webhookSchedulerDisplayName); - expect(scheduler).toBeTruthy(); - const projectResponse = await page.request.post('/agentcompose.v2.ProjectService/GetProject', { - data: { project: { projectId: scheduler!.projectId }, includeSpec: true }, - }); - const projectBody = (await projectResponse.json()) as { - project: { spec: { agents: Array> } }; - }; const agents = projectBody.project.spec.agents.map((agent) => { const driver = (agent.driver as { name?: string; docker?: unknown } | undefined)?.name === 'docker' @@ -400,9 +402,13 @@ async function configureWebhookAutomation(page: Page, script: string): Promise { - const response = await page.request.post('/agentcompose.v2.ProjectService/ListSchedulers', { data: {} }); - const body = (await response.json()) as { schedulers: typeof schedulers.schedulers }; - return body.schedulers.find((item) => item.displayName === webhookSchedulerDisplayName)?.triggerCount ?? 0; + const response = await page.request.post('/agentcompose.v2.ProjectService/GetProject', { + data: { project: { projectId: projectBody.project.summary.projectId } }, + }); + const body = (await response.json()) as { project: { schedulers: typeof projectBody.project.schedulers } }; + return ( + body.project.schedulers.find((item) => item.displayName === webhookSchedulerDisplayName)?.triggerCount ?? 0 + ); }) .toBe(2); } @@ -495,7 +501,7 @@ test('authenticates and loads every primary route without browser errors', async }); await page.goto('/'); - await expect(page.getByRole('heading', { name: 'agent-compose' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Agent-Compose', exact: true })).toBeVisible(); await page.getByLabel('用户名').fill('admin'); await page.getByLabel('密码').fill('wrong-password'); await page.getByRole('button', { name: '登录', exact: true }).click(); @@ -593,7 +599,7 @@ test('keeps product terminology concise and raw audit values in request details' }); await login(page); - for (const route of ['/projects', '/automations', '/sandboxes', '/events', '/settings', '/audit']) { + for (const route of ['/projects', '/sandboxes', '/events', '/settings', '/audit']) { await navigateInApp(page, route); const visibleText = await page.locator('body').innerText(); expect(visibleText).not.toMatch( @@ -646,16 +652,7 @@ test('keeps primary pages within phone and tablet viewports', async ({ page }) = { width: 768, height: 1024 }, ]) { await page.setViewportSize(viewport); - for (const route of [ - '/', - '/projects', - '/automations', - '/sandboxes', - '/runs/unlinked', - '/events', - '/settings', - '/audit', - ]) { + for (const route of ['/', '/projects', '/sandboxes', '/runs/unlinked', '/events', '/settings', '/audit']) { await navigateInApp(page, route); await page.waitForTimeout(100); const dimensions = await page.evaluate(() => ({ @@ -1066,7 +1063,13 @@ test('loads the code editor only after intent and keeps the transition responsiv await page.waitForTimeout(500); expect(editorRequests, 'the overview should not preload Monaco').toEqual([]); - await navigateInApp(page, '/automations'); + const projectsResponse = await page.request.get('/api/ui/v1/projects'); + const projectsBody = (await projectsResponse.json()) as { + projects: Array<{ projectId: string; editable: boolean; agents: Array }>; + }; + const project = projectsBody.projects.find((item) => item.editable && item.agents.length > 0); + test.skip(!project, 'No editable Project with Agents is available'); + await navigateInApp(page, `/projects/${encodeURIComponent(project!.projectId)}/automations`); const createButton = page.getByRole('button', { name: '配置自动化' }); await createButton.hover(); await page.waitForTimeout(500); @@ -1271,7 +1274,7 @@ test('keeps execution actions sticky and restores list scroll on browser history test('opens the live webhook event from the authenticated event center', async ({ page }) => { test.skip(!retainedLiveWebhookEventId, 'requires a retained webhook event'); await page.goto(`/events/${retainedLiveWebhookEventId}`); - await expect(page.getByRole('heading', { name: 'agent-compose' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Agent-Compose', exact: true })).toBeVisible(); await page.getByLabel('用户名').fill('admin'); await page.getByLabel('密码').fill(e2ePassword); await page.getByRole('button', { name: '登录', exact: true }).click(); @@ -2470,15 +2473,16 @@ test('shows conversation send feedback before a stream request finishes', async } }); -test('groups agents by project and prioritizes enabled automations', async ({ page }) => { +test('keeps automations under their Project and prioritizes enabled tasks', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await login(page); const projectResponse = await page.request.get('/api/ui/v1/projects'); const projectBody = (await projectResponse.json()) as { - projects: Array<{ name: string; agents: Array<{ displayName: string }> }>; + projects: Array<{ projectId: string; name: string; agents: Array<{ displayName: string }> }>; }; const project = projectBody.projects.find((item) => item.agents.length > 0); test.skip(!project, 'No Project with Agents is available'); + await expect(page.getByRole('navigation').getByRole('button', { name: '自动化', exact: true })).toHaveCount(0); await navigateInApp(page, '/projects'); await expect(page.getByRole('heading', { name: '项目', exact: true })).toBeVisible(); await page.locator('aside').getByText(project!.name, { exact: true }).click(); @@ -2486,7 +2490,8 @@ test('groups agents by project and prioritizes enabled automations', async ({ pa await page.getByPlaceholder('搜索项目或智能体…').fill('__no_matching_project__'); await expect(page.getByText('没有匹配的项目')).toBeVisible(); - await navigateInApp(page, '/automations'); + await navigateInApp(page, `/projects/${encodeURIComponent(project!.projectId)}/automations`); + await expect(page).toHaveURL(new RegExp(`/projects/${project!.projectId}/automations`)); const automationFilters = page.getByLabel('自动化状态筛选'); await expect(automationFilters.getByRole('button', { name: /已启用 \d+/, pressed: true })).toBeVisible(); const automationRows = page.locator('tbody tr').filter({ has: page.getByText('已启用', { exact: true }) }); @@ -2517,7 +2522,7 @@ test('previews Agent automation as a Project deployment without applying it', as await navigateInApp( page, - `/automations/new?project=${encodeURIComponent(target!.project.projectId)}&agent=${encodeURIComponent(target!.agent.agentName)}`, + `/projects/${encodeURIComponent(target!.project.projectId)}/automations/new?agent=${encodeURIComponent(target!.agent.agentName)}`, ); await expect(page.getByLabel('所属项目 / 智能体')).not.toHaveValue(''); await page.getByLabel('名称').fill('Preview only automation'); @@ -2635,7 +2640,7 @@ test('keeps the automation editor usable on mobile', async ({ page }) => { await navigateInApp( page, - `/automations/new?project=${encodeURIComponent(target!.project.projectId)}&agent=${encodeURIComponent(target!.agent.agentName)}`, + `/projects/${encodeURIComponent(target!.project.projectId)}/automations/new?agent=${encodeURIComponent(target!.agent.agentName)}`, ); await expect(page.getByLabel('所属项目 / 智能体')).not.toHaveValue(''); await expect(page.getByRole('heading', { name: '基本信息', exact: true })).toBeVisible(); @@ -2665,12 +2670,17 @@ test('fills the owning Project and Agent when editing automation', async ({ page await page.setViewportSize({ width: 1440, height: 900 }); await login(page); await page.evaluate(() => localStorage.removeItem('ac.automationConfigPaneWidth')); - await navigateInApp(page, '/automations'); + const projects = (await (await page.request.get('/api/ui/v1/projects')).json()) as { + projects: Array<{ projectId: string; name: string }>; + }; + const project = projects.projects.find((item) => item.name === 'ui-agents'); + test.skip(!project, 'ui-agents Project is unavailable'); + await navigateInApp(page, `/projects/${encodeURIComponent(project!.projectId)}/automations`); const row = page.locator('tbody tr').filter({ hasText: 'UI Agent Shell Regression' }); await row.getByRole('button', { name: '编辑' }).click(); const owner = page.getByLabel('所属项目 / 智能体'); await expect(owner).not.toHaveValue(''); - await expect(owner.locator('option:checked')).toContainText('ui-agents / LLM Shell Regression Agent'); + await expect(owner.locator('option:checked')).toContainText('LLM Shell Regression Agent'); const pane = page.locator('section[data-scroll-pane]'); const handle = page.getByRole('button', { name: '调整配置栏宽度' }); @@ -2833,7 +2843,7 @@ test('preserves a Cron timezone through an automation deployment', async ({ page const { AgentSpec, ProjectSpec, SchedulerSpec, TriggerKind, TriggerSpec } = await import('/src/gen/agentcompose/v2/agentcompose_pb.ts'); const projects = await listProjectViews(); - const tasks = await listAutomationTasks(); + const tasks = (await Promise.all(projects.map((project) => listAutomationTasks(project.projectId)))).flat(); const target = tasks.find((task) => projects.some( (project) => @@ -2886,8 +2896,8 @@ test('preserves a Cron timezone through an automation deployment', async ({ page let timezone = ''; let operationError = ''; try { - const detail = await getAutomationTask(target.id); - const preview = await previewAutomationTask({ + const detail = await getAutomationTask(target.projectId, target.agentName); + const preview = await previewAutomationTask(target.projectId, target.agentName, { ...detail, name: `${detail.name} Timezone Roundtrip`, triggers: detail.configuredTriggers, @@ -3332,7 +3342,13 @@ test('runs a real LLM conversation and an automation agent shell task', async ({ await expect(chatPanel.getByRole('button', { name: '查看运行', exact: true }).last()).toBeVisible(); } - await page.getByRole('button', { name: '自动化', exact: true }).click(); + const automationProjects = (await (await page.request.get('/api/ui/v1/projects')).json()) as { + projects: Array<{ projectId: string; name: string }>; + }; + const automationProject = automationProjects.projects.find((item) => item.name === 'ui-agents'); + expect(automationProject).toBeTruthy(); + const automationPath = `/projects/${encodeURIComponent(automationProject!.projectId)}/automations`; + await navigateInApp(page, automationPath); await expect(page.getByRole('heading', { name: '自动化' })).toBeVisible(); const taskName = webhookSchedulerDisplayName; const taskRow = page.locator('tbody tr:visible, article:visible').filter({ hasText: taskName }); @@ -3358,12 +3374,15 @@ test('runs a real LLM conversation and an automation agent shell task', async ({ if (await confirmDeployment.isEnabled()) await confirmDeployment.click(); else { await deploymentDialog.getByRole('button', { name: '取消' }).click(); - await navigateInApp(page, '/automations'); + await navigateInApp(page, automationPath); } await expect(taskRow).toBeVisible({ timeout: 30_000 }); const resumedAutomationRunId = process.env.AGENT_COMPOSE_E2E_RESUME_AUTOMATION_RUN_ID; if (resumedAutomationRunId) { - await navigateInApp(page, `/automation-runs/${resumedAutomationRunId}`); + await navigateInApp( + page, + `/projects/${encodeURIComponent(automationProject!.projectId)}/automation-runs/${encodeURIComponent(resumedAutomationRunId)}`, + ); } else { await taskRow.getByRole('button', { name: '运行', exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: '开始运行' }).click();
- {taskAgent?.projectName || task.projectId.slice(0, 8)} / {taskAgent?.name || task.agentName} + {project?.name || task.projectId.slice(0, 8)} / {taskAgent?.name || task.agentName}
{t('保存前预览项目变更')}
{t('最近 {count} 条', { count: recentPreview.length })}
- {t('暂无失败运行')} -
{t('项目运行与自动化运行的最新记录')}
- {t('当前没有运行中的任务')} -