From 8fc37c5a139af28983f67e3cb6c7425a31292d56 Mon Sep 17 00:00:00 2001 From: Abigail Deng Date: Wed, 5 Aug 2026 00:03:15 +0800 Subject: [PATCH 01/18] Implement Workflow Activity vNext --- apps/aevatar-console-web/config/routes.ts | 35 + ...-workflow-activity-vnext-implementation.md | 99 +++ apps/aevatar-console-web/src/locales/en-US.ts | 2 + .../workflowActivityVNextMessages.en-US.ts | 239 +++++++ .../workflowActivityVNextMessages.zh-CN.ts | 242 +++++++ apps/aevatar-console-web/src/locales/zh-CN.ts | 2 + .../WorkflowActivityVNextShell.tsx | 98 +++ .../activity/ActivityPage.test.tsx | 104 +++ .../activity/ActivityPage.tsx | 95 +++ .../activity/RunDetailPage.test.tsx | 237 +++++++ .../activity/RunDetailPage.tsx | 120 ++++ .../activity/runRecovery.test.ts | 51 ++ .../activity/runRecovery.ts | 33 + .../hooks/useConsoleLocation.ts | 18 + .../hooks/useDraftMaterialization.test.ts | 62 ++ .../hooks/useDraftMaterialization.ts | 101 +++ .../hooks/useRunObservation.ts | 11 + .../hooks/useWorkflowEditor.ts | 287 ++++++++ .../workflow-activity-vnext/index.test.tsx | 623 ++++++++++++++++++ .../pages/workflow-activity-vnext/index.tsx | 58 ++ .../workflow-activity-vnext/navigation.ts | 34 + .../settings/SettingsPage.tsx | 138 ++++ .../pages/workflow-activity-vnext/styles.ts | 97 +++ .../workflows/NewWorkflowPage.tsx | 206 ++++++ .../workflows/WorkflowEditorPage.tsx | 102 +++ .../workflows/WorkflowsPage.tsx | 206 ++++++ .../workflows/workflowCreation.ts | 60 ++ .../src/routesConfig.test.ts | 36 + .../shared/api/workflowActivityApi.test.ts | 219 ++++++ .../src/shared/api/workflowActivityApi.ts | 486 ++++++++++++++ .../src/shared/models/workflowActivity.ts | 151 +++++ 31 files changed, 4252 insertions(+) create mode 100644 apps/aevatar-console-web/docs/superpowers/plans/2026-08-04-workflow-activity-vnext-implementation.md create mode 100644 apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts create mode 100644 apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useConsoleLocation.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.test.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowEditor.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/navigation.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/settings/SettingsPage.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowsPage.tsx create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowCreation.ts create mode 100644 apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts create mode 100644 apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts create mode 100644 apps/aevatar-console-web/src/shared/models/workflowActivity.ts diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index 9a556947d9..c9881b1dad 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -40,6 +40,41 @@ export default [ menuGroupKey: "teams", hideInMenu: false, }, + { + path: "/scopes/:scopeId/workflow-activity-vnext", + redirect: "/scopes/:scopeId/workflow-activity-vnext/workflows", + hideInMenu: true, + }, + { + path: "/scopes/:scopeId/workflow-activity-vnext/workflows", + component: "./workflow-activity-vnext", + hideInMenu: true, + }, + { + path: "/scopes/:scopeId/workflow-activity-vnext/workflows/new", + component: "./workflow-activity-vnext", + hideInMenu: true, + }, + { + path: "/scopes/:scopeId/workflow-activity-vnext/workflows/:workflowId", + component: "./workflow-activity-vnext", + hideInMenu: true, + }, + { + path: "/scopes/:scopeId/workflow-activity-vnext/activity", + component: "./workflow-activity-vnext", + hideInMenu: true, + }, + { + path: "/scopes/:scopeId/workflow-activity-vnext/activity/:runId", + component: "./workflow-activity-vnext", + hideInMenu: true, + }, + { + path: "/scopes/:scopeId/workflow-activity-vnext/settings", + component: "./workflow-activity-vnext", + hideInMenu: true, + }, { path: "/scopes/:scopeId/teams/new", name: "Create Team", diff --git a/apps/aevatar-console-web/docs/superpowers/plans/2026-08-04-workflow-activity-vnext-implementation.md b/apps/aevatar-console-web/docs/superpowers/plans/2026-08-04-workflow-activity-vnext-implementation.md new file mode 100644 index 0000000000..627f4ae6ab --- /dev/null +++ b/apps/aevatar-console-web/docs/superpowers/plans/2026-08-04-workflow-activity-vnext-implementation.md @@ -0,0 +1,99 @@ +# Workflow Activity vNext Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver every approved Workflow Activity vNext user path under the isolated scoped route namespace using only authoritative backend data and existing authentication/localization behavior. + +**Architecture:** The route package owns page composition and transient interaction state. Existing Studio, scope, runtime, auth, and Settings adapters remain authoritative; one new typed adapter owns observatory and fork transport decoding. TanStack Query keys include the route scope and normalized filters, while accepted-to-readable and accepted-to-observed transitions use explicit receipt-bound state machines. + +**Tech Stack:** React 19, TypeScript, Umi Max, Ant Design, TanStack Query, XYFlow/GraphCanvas, Jest, Testing Library, Biome. + +--- + +### Task 1: Isolated routes and typed transport boundary + +**Files:** +- Modify: `config/routes.ts` +- Modify: `src/routesConfig.test.ts` +- Create: `src/shared/models/workflowActivity.ts` +- Create: `src/shared/api/workflowActivityApi.ts` +- Create: `src/shared/api/workflowActivityApi.test.ts` + +- [ ] Write route assertions for all seven hidden vNext routes, literal `workflows/new` ordering, namespace-only redirect, and unchanged legacy redirects; run the single route test and confirm RED. +- [ ] Add precise observatory summary/detail/graph, filter, and fork receipt models plus decoders that preserve unknown status values and reject malformed required identity/version fields. +- [ ] Write adapter tests for encoded scope/filter queries, independent detail/graph requests, 401/403/404 propagation, fork request identity, and receipt decoding; run and confirm RED before implementation. +- [ ] Implement the seven route records and typed adapter, then rerun only `src/routesConfig.test.ts` and `src/shared/api/workflowActivityApi.test.ts` to GREEN. + +### Task 2: Scoped shell, catalogue, and direct creation + +**Files:** +- Create: `src/pages/workflow-activity-vnext/index.tsx` +- Create: `src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx` +- Create: `src/pages/workflow-activity-vnext/navigation.ts` +- Create: `src/pages/workflow-activity-vnext/workflows/WorkflowsPage.tsx` +- Create: `src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.tsx` +- Create: `src/pages/workflow-activity-vnext/workflows/workflowCreation.ts` +- Create: `src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.ts` +- Create: `src/pages/workflow-activity-vnext/index.test.tsx` + +- [ ] Write route integration tests for authoritative catalogue loading, successful empty data, partial-source failure, search, and real-ID navigation; run the new page test and confirm RED. +- [ ] Write creation tests for Describe, blank, YAML import, and bundled versioned template paths. Cover materialized and `202 projection_pending` responses, bounded `404`, preserved receipt/input, retrying the same GET, and duplicate-submit prevention; confirm RED. +- [ ] Implement the local rail/mobile navigation with existing `ConsoleLanguageSwitch` and `ConsoleAuthActions`, two-source catalogue, four direct creation forms, and receipt-bound materialization hook. +- [ ] Rerun only the new route test after each user-path slice until UP-00 through UP-05 are GREEN. + +### Task 3: Common editor, first save, and draft execution + +**Files:** +- Create: `src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx` +- Create: `src/pages/workflow-activity-vnext/hooks/useWorkflowEditor.ts` +- Create: `src/pages/workflow-activity-vnext/hooks/useDraftRun.ts` +- Reuse: `src/pages/team-member-workflow-studio/components/WorkflowStudioCanvas.tsx` +- Reuse: `src/pages/team-member-workflow-studio/components/WorkflowStudioNodeLibrary.tsx` +- Reuse: `src/pages/team-member-workflow-studio/components/WorkflowStudioNodeDetailPanel.tsx` +- Reuse: `src/pages/team-member-workflow-studio/components/WorkflowStudioYamlPanel.tsx` +- Reuse: `src/pages/team-member-workflow-studio/components/WorkflowStudioDraftRunPanel.tsx` + +- [ ] Write editor tests for exact draft loading, committed fallback, dirty state, YAML/canvas shared state, validation findings, existing-draft PUT, and committed-only first-save POST with returned-ID route replacement; confirm RED. +- [ ] Implement the editor hook and page using existing Studio graph/document helpers without member APIs or member identity. +- [ ] Write draft-run tests for real serialized YAML, stable submission, accepted/running state, disconnected/error state, no Activity completion claim, and general Open Activity when no trustworthy run ID exists; confirm RED. +- [ ] Implement `runtimeRunsApi.streamDraftRun` integration and authoritative stream presentation, then rerun the route test to GREEN for UP-06 and UP-07. + +### Task 4: Activity observation, ledger, detail, and recovery + +**Files:** +- Create: `src/pages/workflow-activity-vnext/activity/ActivityPage.tsx` +- Create: `src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx` +- Create: `src/pages/workflow-activity-vnext/activity/runRecovery.ts` +- Create: `src/pages/workflow-activity-vnext/hooks/useRunObservation.ts` + +- [ ] Write tests for URL-backed server filters, workflow definition resolution, filter-unavailable fallback, loading/empty/error/unknown statuses, and recent-window wording; confirm RED. +- [ ] Implement the Activity query and ledger without name joins, local totals, revisions, duration, usage, outcome, or Needs-you filtering. +- [ ] Write tests for independent detail/graph loading, safe not-found, partial running detail, graph-only failure, failed-step eligibility, first-executable-step eligibility, immutable source, fork receipt display, and no `newRunActorId` detail navigation; confirm RED. +- [ ] Implement detail, graph, Retry, Run again, and bounded observation, then rerun the route test to GREEN for UP-08 through UP-12. + +### Task 5: Settings, identity, responsive behavior, and locale + +**Files:** +- Create: `src/pages/workflow-activity-vnext/settings/SettingsPage.tsx` +- Create: `src/pages/workflow-activity-vnext/styles.ts` +- Modify: `src/locales/en-US.ts` +- Modify: `src/locales/zh-CN.ts` +- Modify: `src/locales/projectMessages.en-US.ts` +- Modify: `src/locales/projectMessages.zh-CN.ts` +- Modify: `src/locales/catalog.test.ts` + +- [ ] Write tests for real LLM loading/dirty/accepted/observed/catalogue-unavailable/save-failure states, auth-me identity/expiry, existing auth actions, runtime loading/unavailable, and no localStorage authority; confirm RED. +- [ ] Implement AI Defaults by reusing `userLlmSelection.ts` and `observeUserLlmSave`, Account through existing session/actions, and Advanced through `getUserConfigRuntime`. +- [ ] Add every new message to both locale catalogues and verify catalogue parity with the single locale test. +- [ ] Add responsive Operational Automation Ledger styles and test keyboard-visible controls, semantic status, dialogs, long identities, and mobile section navigation; rerun only the new page and locale tests to GREEN for UP-13 through UP-16. + +### Task 6: Focused verification and delivery + +**Files:** +- Review all files changed relative to `origin/feat/2026-08-04_workflow-activity-vnext` + +- [ ] Run `frontend_change_scope.py --base origin/feat/2026-08-04_workflow-activity-vnext`, explicitly execute every changed/new Jest file and dependency-related tests, then run Biome only on reported static-check files. +- [ ] Because the user explicitly requested full TypeScript and production-build validation, run the package `tsc` and `build` with `CODEX_ALLOW_FULL_FRONTEND_VALIDATION=1`; do not run the complete Jest suite. +- [ ] Run the design baseline verifier and confirm the declared SHA, byte-identical generator output, and 17/17 frames. +- [ ] After the environment synchronizer succeeds, verify real authenticated desktop/tablet/mobile routes in-browser and capture screenshots. If the synchronizer/backend/OAuth environment remains unavailable, record the exact gap without mocks. +- [ ] Review the complete frontend-only diff, stage only this task's files, commit with an imperative message, push, and create a Draft implementation PR without modifying PR #3187 or enabling auto-merge. diff --git a/apps/aevatar-console-web/src/locales/en-US.ts b/apps/aevatar-console-web/src/locales/en-US.ts index 7d63d62608..799c38f378 100644 --- a/apps/aevatar-console-web/src/locales/en-US.ts +++ b/apps/aevatar-console-web/src/locales/en-US.ts @@ -1,7 +1,9 @@ import projectMessages from './projectMessages.en-US'; +import workflowActivityVNextMessages from './workflowActivityVNextMessages.en-US'; const enUSMessages = { ...projectMessages, + ...workflowActivityVNextMessages, 'common.appName': 'Aevatar', 'common.language.english': 'English', 'common.language.label': 'Language', diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts new file mode 100644 index 0000000000..4db11c4aea --- /dev/null +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts @@ -0,0 +1,239 @@ +const workflowActivityVNextMessages = { + "workflowActivityVNext.activity.allOrigins": "All origins", + "workflowActivityVNext.activity.allStatuses": "All statuses", + "workflowActivityVNext.activity.clearWorkflowFilter": "Clear workflow filter", + "workflowActivityVNext.activity.columnOrigin": "Origin", + "workflowActivityVNext.activity.columnRun": "Run", + "workflowActivityVNext.activity.columnStatus": "Status", + "workflowActivityVNext.activity.columnUpdated": "Observed update", + "workflowActivityVNext.activity.columnVersion": "State version", + "workflowActivityVNext.activity.description": "Immutable run records observed by the workflow read model.", + "workflowActivityVNext.activity.empty": "No observed runs", + "workflowActivityVNext.activity.emptyDescription": "Activity appears only after the observatory read model returns a run.", + "workflowActivityVNext.activity.loading": "Loading observed runs", + "workflowActivityVNext.activity.noMatch": "No matching observed runs", + "workflowActivityVNext.activity.openRunAria": "Open run {runId}", + "workflowActivityVNext.activity.originDraft": "Draft", + "workflowActivityVNext.activity.originFilter": "Run origin", + "workflowActivityVNext.activity.originMember": "Member invoke", + "workflowActivityVNext.activity.originSchedule": "Schedule", + "workflowActivityVNext.activity.search": "Search observed runs", + "workflowActivityVNext.activity.searchAria": "Search observed runs", + "workflowActivityVNext.activity.statusFilter": "Run status", + "workflowActivityVNext.activity.title": "Activity", + "workflowActivityVNext.activity.unavailable": "Activity unavailable", + "workflowActivityVNext.activity.unnamed": "Unnamed workflow", + "workflowActivityVNext.activity.window": "Showing up to 100 recently observed runs. This is not a lifetime total.", + "workflowActivityVNext.activity.workflowFilterUnavailable": "Workflow filter unavailable; showing unfiltered Activity", + "workflowActivityVNext.brand.subtitle": "AUTOMATION LEDGER", + "workflowActivityVNext.common.cancel": "Cancel", + "workflowActivityVNext.common.accepted": "Accepted", + "workflowActivityVNext.common.close": "Close", + "workflowActivityVNext.common.empty": "Empty", + "workflowActivityVNext.common.failed": "Failed", + "workflowActivityVNext.common.no": "No", + "workflowActivityVNext.common.open": "Open", + "workflowActivityVNext.common.pending": "Pending", + "workflowActivityVNext.common.refresh": "Refresh", + "workflowActivityVNext.common.retry": "Retry", + "workflowActivityVNext.common.run": "Run", + "workflowActivityVNext.common.succeeded": "Succeeded", + "workflowActivityVNext.common.unavailable": "Unavailable", + "workflowActivityVNext.common.unknown": "Unknown", + "workflowActivityVNext.common.yes": "Yes", + "workflowActivityVNext.editor.addNode": "Add node", + "workflowActivityVNext.editor.backAria": "Back to workflows", + "workflowActivityVNext.editor.canvas": "Canvas", + "workflowActivityVNext.editor.committedOnly": "Committed source · first Save creates a scoped draft", + "workflowActivityVNext.editor.discardLeave": "Discard and leave", + "workflowActivityVNext.editor.draft": "Scoped workflow draft", + "workflowActivityVNext.editor.emptyCanvas": "Add the first executable node to make this workflow runnable.", + "workflowActivityVNext.editor.eventCount": "Received {count} stream events", + "workflowActivityVNext.editor.loading": "Loading workflow", + "workflowActivityVNext.editor.loadingDescription": "Loading the authoritative workflow document.", + "workflowActivityVNext.editor.openActivity": "Open Activity", + "workflowActivityVNext.editor.publish": "Publish", + "workflowActivityVNext.editor.publishUnavailable": "Publishing is unavailable until the backend supplies an explicit published service identity.", + "workflowActivityVNext.editor.runAccepted": "Draft run accepted; receiving stream", + "workflowActivityVNext.editor.runFailed": "Draft run failed", + "workflowActivityVNext.editor.runInput": "Draft run input", + "workflowActivityVNext.editor.runPanel": "Draft run", + "workflowActivityVNext.editor.runUnavailable": "Run requires a parsed workflow with at least one executable step and no blocking findings.", + "workflowActivityVNext.editor.runSubmitting": "Submitting draft run", + "workflowActivityVNext.editor.save": "Save workflow", + "workflowActivityVNext.editor.saveLeave": "Save and leave", + "workflowActivityVNext.editor.saved": "Saved", + "workflowActivityVNext.editor.stay": "Stay", + "workflowActivityVNext.editor.streamEnded": "Run stream ended; Activity observation is still authoritative", + "workflowActivityVNext.editor.submitRun": "Submit draft run", + "workflowActivityVNext.editor.unavailable": "Workflow unavailable", + "workflowActivityVNext.editor.unavailableDescription": "The workflow could not be loaded from its scoped API.", + "workflowActivityVNext.editor.unsaved": "Unsaved", + "workflowActivityVNext.editor.unsavedDescription": "Save this authoritative draft, discard the local edits, or stay in the editor.", + "workflowActivityVNext.editor.unsavedTitle": "Unsaved workflow changes", + "workflowActivityVNext.editor.untitled": "Untitled workflow", + "workflowActivityVNext.editor.yaml": "YAML", + "workflowActivityVNext.nav.activity": "Activity", + "workflowActivityVNext.nav.aria": "Workflow workbench", + "workflowActivityVNext.nav.settings": "Settings", + "workflowActivityVNext.nav.workflows": "Workflows", + "workflowActivityVNext.new.back": "Back to workflows", + "workflowActivityVNext.new.changeMethod": "Change method", + "workflowActivityVNext.new.chooserAria": "Workflow creation methods", + "workflowActivityVNext.new.createBlank": "Create blank draft", + "workflowActivityVNext.new.createGenerated": "Create generated draft", + "workflowActivityVNext.new.createTemplate": "Create from template", + "workflowActivityVNext.new.description": "Choose a direct path to a persisted workflow draft.", + "workflowActivityVNext.new.directory": "Workflow directory", + "workflowActivityVNext.new.generate": "Generate draft", + "workflowActivityVNext.new.generatedYaml": "Generated YAML", + "workflowActivityVNext.new.goal": "Automation goal", + "workflowActivityVNext.new.mode.blank": "Start blank", + "workflowActivityVNext.new.mode.blank.description": "Persist an empty workflow document and add its first node in the editor.", + "workflowActivityVNext.new.mode.describe": "Describe", + "workflowActivityVNext.new.mode.describe.description": "Generate a draft from a goal, review it, then persist it.", + "workflowActivityVNext.new.mode.import": "Import YAML", + "workflowActivityVNext.new.mode.import.description": "Validate YAML with the editor API before creating a draft.", + "workflowActivityVNext.new.mode.template": "Use template", + "workflowActivityVNext.new.mode.template.description": "Copy versioned bundled product content into an independent persisted draft.", + "workflowActivityVNext.new.name": "Workflow name", + "workflowActivityVNext.new.noDirectories": "No server workflow directory is available for draft creation.", + "workflowActivityVNext.new.observationFailed": "Draft observation failed", + "workflowActivityVNext.new.observing": "Draft accepted; observing readability", + "workflowActivityVNext.new.projectionDelayed": "Projection is delayed", + "workflowActivityVNext.new.receipt": "Workflow {workflowId} · command {commandId}", + "workflowActivityVNext.new.retryObservation": "Retry draft observation", + "workflowActivityVNext.new.template": "Template", + "workflowActivityVNext.new.templateDescription.incidentTriage": "Classify an incident, prepare a response, and request human approval.", + "workflowActivityVNext.new.templateName.incidentTriage": "Incident triage", + "workflowActivityVNext.new.templateVersion": "Bundled template version {version}", + "workflowActivityVNext.new.title": "New workflow", + "workflowActivityVNext.new.validateCreate": "Validate and create", + "workflowActivityVNext.new.workspaceLoading": "Loading workspace directories", + "workflowActivityVNext.new.workspaceUnavailable": "Workspace directories unavailable", + "workflowActivityVNext.new.yaml": "Workflow YAML", + "workflowActivityVNext.run.backAria": "Back to Activity", + "workflowActivityVNext.run.commandId": "Command ID", + "workflowActivityVNext.run.code": "Code", + "workflowActivityVNext.run.completedSteps": "Completed steps", + "workflowActivityVNext.run.completionTokens": "Completion tokens", + "workflowActivityVNext.run.confirmRetry": "Confirm retry", + "workflowActivityVNext.run.confirmRunAgain": "Confirm run again", + "workflowActivityVNext.run.confirmTitle": "Confirm new run", + "workflowActivityVNext.run.correlationId": "Correlation ID", + "workflowActivityVNext.run.cost": "Returned cost", + "workflowActivityVNext.run.diagnostics": "Diagnostics", + "workflowActivityVNext.run.error": "Final error", + "workflowActivityVNext.run.forkAccepted": "New run accepted; waiting for independent Activity observation", + "workflowActivityVNext.run.forkReceipt": "Actor {actorId} · command {commandId}", + "workflowActivityVNext.run.graph": "Graph", + "workflowActivityVNext.run.graphLoading": "Loading run graph", + "workflowActivityVNext.run.graphSummary": "{nodes} nodes · {edges} edges", + "workflowActivityVNext.run.graphUnavailable": "Run graph unavailable; committed detail remains visible", + "workflowActivityVNext.run.immutable": "Immutable observed run · source facts are never edited by recovery actions", + "workflowActivityVNext.run.input": "Input", + "workflowActivityVNext.run.loading": "Loading run", + "workflowActivityVNext.run.loadingDescription": "Loading committed run facts.", + "workflowActivityVNext.run.newActorId": "New run actor ID", + "workflowActivityVNext.run.noSteps": "No committed steps are visible yet.", + "workflowActivityVNext.run.noDiagnostics": "No diagnostics were returned.", + "workflowActivityVNext.run.noTimeline": "No timeline events are visible yet.", + "workflowActivityVNext.run.output": "Final output", + "workflowActivityVNext.run.message": "Message", + "workflowActivityVNext.run.promptTokens": "Prompt tokens", + "workflowActivityVNext.run.requestedSteps": "Requested steps", + "workflowActivityVNext.run.requestParameters": "Request parameters", + "workflowActivityVNext.run.retry": "Retry failed step", + "workflowActivityVNext.run.retryUnavailable": "Retry requires exactly one failed step ID.", + "workflowActivityVNext.run.runAgain": "Run again", + "workflowActivityVNext.run.runAgainUnavailable": "Run again requires an explicit first execution step.", + "workflowActivityVNext.run.roleReplies": "Role replies", + "workflowActivityVNext.run.severity": "Severity", + "workflowActivityVNext.run.sourceImmutable": "The source run remains immutable. This command requests a new run.", + "workflowActivityVNext.run.sourceRunId": "Source run ID", + "workflowActivityVNext.run.startStepId": "Start step ID", + "workflowActivityVNext.run.stateVersion": "State version {version}", + "workflowActivityVNext.run.statusUrl": "Observation status URL", + "workflowActivityVNext.run.statisticsUsage": "Statistics and usage", + "workflowActivityVNext.run.step": "Step", + "workflowActivityVNext.run.steps": "Steps", + "workflowActivityVNext.run.timeline": "Timeline", + "workflowActivityVNext.run.totalSteps": "Total steps", + "workflowActivityVNext.run.totalTokens": "Total tokens", + "workflowActivityVNext.run.type": "Type", + "workflowActivityVNext.run.unavailable": "Run unavailable", + "workflowActivityVNext.run.unavailableDescription": "The immutable run detail is unavailable.", + "workflowActivityVNext.scope": "Scope", + "workflowActivityVNext.settings.accepted": "Update accepted; observing authoritative settings", + "workflowActivityVNext.settings.account": "Account", + "workflowActivityVNext.settings.accountLoading": "Loading account session", + "workflowActivityVNext.settings.accountUnavailable": "Account session unavailable", + "workflowActivityVNext.settings.activeRuntime": "Active runtime URL", + "workflowActivityVNext.settings.advanced": "Advanced", + "workflowActivityVNext.settings.ai": "AI defaults", + "workflowActivityVNext.settings.authenticated": "Authenticated", + "workflowActivityVNext.settings.catalogUnavailable": "The provider catalogue is unavailable. Saved values remain visible, but unverifiable choices cannot be invented.", + "workflowActivityVNext.settings.delayed": "Update accepted but not yet observed", + "workflowActivityVNext.settings.description": "Personal AI defaults, session identity, and effective runtime context.", + "workflowActivityVNext.settings.discard": "Discard changes", + "workflowActivityVNext.settings.email": "Email", + "workflowActivityVNext.settings.expires": "Expires", + "workflowActivityVNext.settings.failed": "AI defaults could not be saved", + "workflowActivityVNext.settings.llmLoading": "Loading AI defaults", + "workflowActivityVNext.settings.llmUnavailable": "AI defaults unavailable", + "workflowActivityVNext.settings.groups": "Groups", + "workflowActivityVNext.settings.localRuntime": "Local runtime URL", + "workflowActivityVNext.settings.model": "Model", + "workflowActivityVNext.settings.name": "Name", + "workflowActivityVNext.settings.notAccepted": "The settings update was not accepted.", + "workflowActivityVNext.settings.observed": "AI defaults observed", + "workflowActivityVNext.settings.providerDefault": "Provider default", + "workflowActivityVNext.settings.provider": "Provider", + "workflowActivityVNext.settings.remoteRuntime": "Remote runtime URL", + "workflowActivityVNext.settings.route": "AI route", + "workflowActivityVNext.settings.roles": "Roles", + "workflowActivityVNext.settings.runtimeLoading": "Loading effective runtime", + "workflowActivityVNext.settings.runtimeMode": "Runtime mode", + "workflowActivityVNext.settings.runtimeUnavailable": "Effective runtime unavailable", + "workflowActivityVNext.settings.save": "Save AI defaults", + "workflowActivityVNext.settings.saving": "Saving AI defaults", + "workflowActivityVNext.settings.selectionReady": "Saved selection is ready", + "workflowActivityVNext.settings.scopeSource": "Scope source", + "workflowActivityVNext.settings.subject": "Subject", + "workflowActivityVNext.settings.systemDefault": "System default", + "workflowActivityVNext.settings.title": "Settings", + "workflowActivityVNext.state.forbidden": "Scope access forbidden", + "workflowActivityVNext.state.unauthorized": "Authentication required", + "workflowActivityVNext.unavailable.body": "The requested capability is not currently available from the frontend route.", + "workflowActivityVNext.unavailable.description": "This vNext surface is not available yet.", + "workflowActivityVNext.unavailable.title": "Unavailable", + "workflowActivityVNext.workflows.columnActions": "Actions", + "workflowActivityVNext.workflows.columnState": "State", + "workflowActivityVNext.workflows.columnUpdated": "Updated", + "workflowActivityVNext.workflows.columnWorkflow": "Workflow", + "workflowActivityVNext.workflows.committed": "Committed", + "workflowActivityVNext.workflows.committedUnavailable": "Committed catalogue unavailable", + "workflowActivityVNext.workflows.description": "Create, edit, and run scoped workflow drafts.", + "workflowActivityVNext.workflows.draft": "Draft", + "workflowActivityVNext.workflows.draftsUnavailable": "Draft catalogue unavailable", + "workflowActivityVNext.workflows.empty": "No workflows yet", + "workflowActivityVNext.workflows.emptyDescription": "Create a persisted draft to begin.", + "workflowActivityVNext.workflows.loading": "Loading workflows", + "workflowActivityVNext.workflows.new": "New workflow", + "workflowActivityVNext.workflows.noMatch": "No matching workflows", + "workflowActivityVNext.workflows.noMatchDescription": "Try a different search.", + "workflowActivityVNext.workflows.openAria": "Open {name}", + "workflowActivityVNext.workflows.refreshAria": "Refresh workflows", + "workflowActivityVNext.workflows.retryAria": "Retry workflows", + "workflowActivityVNext.workflows.runFromEditor": "Open the editor to validate and run this workflow.", + "workflowActivityVNext.workflows.search": "Search workflows", + "workflowActivityVNext.workflows.searchAria": "Search workflows", + "workflowActivityVNext.workflows.title": "Workflows", + "workflowActivityVNext.workflows.unavailable": "Workflows unavailable", + "workflowActivityVNext.workflows.unavailableDescription": "Neither authoritative catalogue source could be loaded.", + "workflowActivityVNext.workflows.activityFilterUnavailable": "Activity filtering requires a backend definition actor ID.", + "workflowActivityVNext.workflows.activityResolutionFailed": "Workflow Activity filter unavailable", + "workflowActivityVNext.workflows.viewActivity": "Activity", +} as const; + +export default workflowActivityVNextMessages; diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts new file mode 100644 index 0000000000..0b171f5ea9 --- /dev/null +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts @@ -0,0 +1,242 @@ +import enUSMessages from "./workflowActivityVNextMessages.en-US"; + +const workflowActivityVNextMessages: Record = { + ...enUSMessages, + "workflowActivityVNext.activity.allOrigins": "全部来源", + "workflowActivityVNext.activity.allStatuses": "全部状态", + "workflowActivityVNext.activity.clearWorkflowFilter": "清除工作流筛选", + "workflowActivityVNext.activity.columnOrigin": "来源", + "workflowActivityVNext.activity.columnRun": "运行记录", + "workflowActivityVNext.activity.columnStatus": "状态", + "workflowActivityVNext.activity.columnUpdated": "观测更新时间", + "workflowActivityVNext.activity.columnVersion": "状态版本", + "workflowActivityVNext.activity.description": "由工作流读模型真实观测到的不可变运行记录。", + "workflowActivityVNext.activity.empty": "暂无已观测运行记录", + "workflowActivityVNext.activity.emptyDescription": "只有观测读模型返回运行记录后,这里才会显示数据。", + "workflowActivityVNext.activity.loading": "正在加载已观测运行记录", + "workflowActivityVNext.activity.noMatch": "没有匹配的已观测运行记录", + "workflowActivityVNext.activity.openRunAria": "打开运行记录 {runId}", + "workflowActivityVNext.activity.originDraft": "草稿", + "workflowActivityVNext.activity.originFilter": "运行来源", + "workflowActivityVNext.activity.originMember": "成员调用", + "workflowActivityVNext.activity.originSchedule": "计划任务", + "workflowActivityVNext.activity.search": "搜索已观测运行记录", + "workflowActivityVNext.activity.searchAria": "搜索已观测运行记录", + "workflowActivityVNext.activity.statusFilter": "运行状态", + "workflowActivityVNext.activity.title": "活动", + "workflowActivityVNext.activity.unavailable": "活动记录不可用", + "workflowActivityVNext.activity.unnamed": "未命名工作流", + "workflowActivityVNext.activity.window": "最多显示最近观测到的 100 条运行记录,这不是全生命周期总数。", + "workflowActivityVNext.activity.workflowFilterUnavailable": "工作流筛选不可用;当前显示未筛选的活动记录", + "workflowActivityVNext.brand.subtitle": "自动化账本", + "workflowActivityVNext.common.cancel": "取消", + "workflowActivityVNext.common.accepted": "已接受", + "workflowActivityVNext.common.close": "关闭", + "workflowActivityVNext.common.empty": "空", + "workflowActivityVNext.common.failed": "失败", + "workflowActivityVNext.common.no": "否", + "workflowActivityVNext.common.open": "打开", + "workflowActivityVNext.common.pending": "等待中", + "workflowActivityVNext.common.refresh": "刷新", + "workflowActivityVNext.common.retry": "重试", + "workflowActivityVNext.common.run": "运行", + "workflowActivityVNext.common.succeeded": "成功", + "workflowActivityVNext.common.unavailable": "不可用", + "workflowActivityVNext.common.unknown": "未知", + "workflowActivityVNext.common.yes": "是", + "workflowActivityVNext.editor.addNode": "添加节点", + "workflowActivityVNext.editor.backAria": "返回工作流列表", + "workflowActivityVNext.editor.canvas": "画布", + "workflowActivityVNext.editor.committedOnly": "仅有已提交版本 · 首次保存将创建 scoped 草稿", + "workflowActivityVNext.editor.discardLeave": "放弃并离开", + "workflowActivityVNext.editor.draft": "Scoped 工作流草稿", + "workflowActivityVNext.editor.emptyCanvas": "添加第一个可执行节点,使此工作流可运行。", + "workflowActivityVNext.editor.eventCount": "已收到 {count} 个流事件", + "workflowActivityVNext.editor.loading": "正在加载工作流", + "workflowActivityVNext.editor.loadingDescription": "正在加载权威工作流文档。", + "workflowActivityVNext.editor.openActivity": "打开活动", + "workflowActivityVNext.editor.publish": "发布", + "workflowActivityVNext.editor.publishUnavailable": "后端提供明确的已发布服务身份后,才能发布。", + "workflowActivityVNext.editor.runAccepted": "草稿运行已接受,正在接收事件流", + "workflowActivityVNext.editor.runFailed": "草稿运行失败", + "workflowActivityVNext.editor.runInput": "草稿运行输入", + "workflowActivityVNext.editor.runPanel": "草稿运行", + "workflowActivityVNext.editor.runUnavailable": "运行需要已解析、至少包含一个可执行步骤且没有阻断问题的工作流。", + "workflowActivityVNext.editor.runSubmitting": "正在提交草稿运行", + "workflowActivityVNext.editor.save": "保存工作流", + "workflowActivityVNext.editor.saveLeave": "保存并离开", + "workflowActivityVNext.editor.saved": "已保存", + "workflowActivityVNext.editor.stay": "留在此处", + "workflowActivityVNext.editor.streamEnded": "运行事件流已结束;活动观测仍以读模型为准", + "workflowActivityVNext.editor.submitRun": "提交草稿运行", + "workflowActivityVNext.editor.unavailable": "工作流不可用", + "workflowActivityVNext.editor.unavailableDescription": "无法从 scoped API 加载此工作流。", + "workflowActivityVNext.editor.unsaved": "未保存", + "workflowActivityVNext.editor.unsavedDescription": "保存权威草稿、放弃本地编辑,或留在编辑器。", + "workflowActivityVNext.editor.unsavedTitle": "工作流有未保存更改", + "workflowActivityVNext.editor.untitled": "未命名工作流", + "workflowActivityVNext.editor.yaml": "YAML", + "workflowActivityVNext.nav.activity": "活动", + "workflowActivityVNext.nav.aria": "工作流工作台", + "workflowActivityVNext.nav.settings": "设置", + "workflowActivityVNext.nav.workflows": "工作流", + "workflowActivityVNext.new.back": "返回工作流列表", + "workflowActivityVNext.new.changeMethod": "更换创建方式", + "workflowActivityVNext.new.chooserAria": "工作流创建方式", + "workflowActivityVNext.new.createBlank": "创建空白草稿", + "workflowActivityVNext.new.createGenerated": "创建生成的草稿", + "workflowActivityVNext.new.createTemplate": "从模板创建", + "workflowActivityVNext.new.description": "选择一种直接创建持久化工作流草稿的方式。", + "workflowActivityVNext.new.directory": "工作流目录", + "workflowActivityVNext.new.generate": "生成草稿", + "workflowActivityVNext.new.generatedYaml": "生成的 YAML", + "workflowActivityVNext.new.goal": "自动化目标", + "workflowActivityVNext.new.mode.blank": "从空白开始", + "workflowActivityVNext.new.mode.blank.description": "持久化空工作流文档,然后在编辑器中添加第一个节点。", + "workflowActivityVNext.new.mode.describe": "描述需求", + "workflowActivityVNext.new.mode.describe.description": "根据目标生成草稿,检查后再持久化。", + "workflowActivityVNext.new.mode.import": "导入 YAML", + "workflowActivityVNext.new.mode.import.description": "创建草稿前,先通过编辑器 API 验证 YAML。", + "workflowActivityVNext.new.mode.template": "使用模板", + "workflowActivityVNext.new.mode.template.description": "将带版本的内置产品内容复制为独立持久化草稿。", + "workflowActivityVNext.new.name": "工作流名称", + "workflowActivityVNext.new.noDirectories": "服务器没有可用于创建草稿的工作流目录。", + "workflowActivityVNext.new.observationFailed": "草稿观测失败", + "workflowActivityVNext.new.observing": "草稿已接受,正在等待可读", + "workflowActivityVNext.new.projectionDelayed": "投影出现延迟", + "workflowActivityVNext.new.receipt": "工作流 {workflowId} · 命令 {commandId}", + "workflowActivityVNext.new.retryObservation": "重试草稿观测", + "workflowActivityVNext.new.template": "模板", + "workflowActivityVNext.new.templateDescription.incidentTriage": "对事件分类、准备响应并请求人工审批。", + "workflowActivityVNext.new.templateName.incidentTriage": "事件分流", + "workflowActivityVNext.new.templateVersion": "内置模板版本 {version}", + "workflowActivityVNext.new.title": "新建工作流", + "workflowActivityVNext.new.validateCreate": "验证并创建", + "workflowActivityVNext.new.workspaceLoading": "正在加载工作区目录", + "workflowActivityVNext.new.workspaceUnavailable": "工作区目录不可用", + "workflowActivityVNext.new.yaml": "工作流 YAML", + "workflowActivityVNext.run.backAria": "返回活动列表", + "workflowActivityVNext.run.commandId": "命令 ID", + "workflowActivityVNext.run.code": "代码", + "workflowActivityVNext.run.completedSteps": "已完成步骤", + "workflowActivityVNext.run.completionTokens": "完成 Token", + "workflowActivityVNext.run.confirmRetry": "确认重试", + "workflowActivityVNext.run.confirmRunAgain": "确认再次运行", + "workflowActivityVNext.run.confirmTitle": "确认新运行", + "workflowActivityVNext.run.correlationId": "关联 ID", + "workflowActivityVNext.run.cost": "后端返回成本", + "workflowActivityVNext.run.diagnostics": "诊断", + "workflowActivityVNext.run.error": "最终错误", + "workflowActivityVNext.run.forkAccepted": "新运行已接受,正在等待活动读模型独立观测", + "workflowActivityVNext.run.forkReceipt": "Actor {actorId} · 命令 {commandId}", + "workflowActivityVNext.run.graph": "图", + "workflowActivityVNext.run.graphLoading": "正在加载运行图", + "workflowActivityVNext.run.graphSummary": "{nodes} 个节点 · {edges} 条边", + "workflowActivityVNext.run.graphUnavailable": "运行图不可用;已提交详情仍然可见", + "workflowActivityVNext.run.immutable": "不可变的已观测运行 · 恢复操作不会编辑源记录", + "workflowActivityVNext.run.input": "输入", + "workflowActivityVNext.run.loading": "正在加载运行记录", + "workflowActivityVNext.run.loadingDescription": "正在加载已提交的运行事实。", + "workflowActivityVNext.run.newActorId": "新运行 Actor ID", + "workflowActivityVNext.run.noSteps": "尚无可见的已提交步骤。", + "workflowActivityVNext.run.noDiagnostics": "后端未返回诊断。", + "workflowActivityVNext.run.noTimeline": "尚无可见的时间线事件。", + "workflowActivityVNext.run.output": "最终输出", + "workflowActivityVNext.run.message": "消息", + "workflowActivityVNext.run.promptTokens": "输入 Token", + "workflowActivityVNext.run.requestedSteps": "已请求步骤", + "workflowActivityVNext.run.requestParameters": "请求参数", + "workflowActivityVNext.run.retry": "重试失败步骤", + "workflowActivityVNext.run.retryUnavailable": "重试需要唯一、明确的失败步骤 ID。", + "workflowActivityVNext.run.runAgain": "再次运行", + "workflowActivityVNext.run.runAgainUnavailable": "再次运行需要明确的首个执行步骤。", + "workflowActivityVNext.run.roleReplies": "角色回复数", + "workflowActivityVNext.run.severity": "严重级别", + "workflowActivityVNext.run.sourceImmutable": "源运行保持不可变。此命令将请求一个新运行。", + "workflowActivityVNext.run.sourceRunId": "源运行 ID", + "workflowActivityVNext.run.startStepId": "起始步骤 ID", + "workflowActivityVNext.run.stateVersion": "状态版本 {version}", + "workflowActivityVNext.run.statusUrl": "观测状态 URL", + "workflowActivityVNext.run.statisticsUsage": "统计与用量", + "workflowActivityVNext.run.step": "步骤", + "workflowActivityVNext.run.steps": "步骤", + "workflowActivityVNext.run.timeline": "时间线", + "workflowActivityVNext.run.totalSteps": "步骤总数", + "workflowActivityVNext.run.totalTokens": "Token 总数", + "workflowActivityVNext.run.type": "类型", + "workflowActivityVNext.run.unavailable": "运行记录不可用", + "workflowActivityVNext.run.unavailableDescription": "不可变运行详情不可用。", + "workflowActivityVNext.scope": "Scope", + "workflowActivityVNext.settings.accepted": "更新已接受,正在观测权威设置", + "workflowActivityVNext.settings.account": "账户", + "workflowActivityVNext.settings.accountLoading": "正在加载账户会话", + "workflowActivityVNext.settings.accountUnavailable": "账户会话不可用", + "workflowActivityVNext.settings.activeRuntime": "当前 Runtime URL", + "workflowActivityVNext.settings.advanced": "高级", + "workflowActivityVNext.settings.ai": "AI 默认设置", + "workflowActivityVNext.settings.authenticated": "已认证", + "workflowActivityVNext.settings.catalogUnavailable": "Provider catalogue 不可用。已保存值仍可见,但不能虚构无法验证的选项。", + "workflowActivityVNext.settings.delayed": "更新已接受但尚未观测到", + "workflowActivityVNext.settings.description": "个人 AI 默认设置、会话身份与当前 Runtime 上下文。", + "workflowActivityVNext.settings.discard": "放弃更改", + "workflowActivityVNext.settings.email": "邮箱", + "workflowActivityVNext.settings.expires": "过期时间", + "workflowActivityVNext.settings.failed": "无法保存 AI 默认设置", + "workflowActivityVNext.settings.llmLoading": "正在加载 AI 默认设置", + "workflowActivityVNext.settings.llmUnavailable": "AI 默认设置不可用", + "workflowActivityVNext.settings.groups": "用户组", + "workflowActivityVNext.settings.localRuntime": "本地 Runtime URL", + "workflowActivityVNext.settings.model": "模型", + "workflowActivityVNext.settings.name": "名称", + "workflowActivityVNext.settings.notAccepted": "设置更新未被接受。", + "workflowActivityVNext.settings.observed": "已观测到 AI 默认设置", + "workflowActivityVNext.settings.providerDefault": "Provider 默认值", + "workflowActivityVNext.settings.provider": "身份 Provider", + "workflowActivityVNext.settings.remoteRuntime": "远端 Runtime URL", + "workflowActivityVNext.settings.route": "AI 路由", + "workflowActivityVNext.settings.roles": "角色", + "workflowActivityVNext.settings.runtimeLoading": "正在加载当前 Runtime", + "workflowActivityVNext.settings.runtimeMode": "Runtime 模式", + "workflowActivityVNext.settings.runtimeUnavailable": "当前 Runtime 不可用", + "workflowActivityVNext.settings.save": "保存 AI 默认设置", + "workflowActivityVNext.settings.saving": "正在保存 AI 默认设置", + "workflowActivityVNext.settings.selectionReady": "已保存的选择可用", + "workflowActivityVNext.settings.scopeSource": "Scope 来源", + "workflowActivityVNext.settings.subject": "Subject", + "workflowActivityVNext.settings.systemDefault": "系统默认值", + "workflowActivityVNext.settings.title": "设置", + "workflowActivityVNext.state.forbidden": "无权访问此 Scope", + "workflowActivityVNext.state.unauthorized": "需要登录", + "workflowActivityVNext.unavailable.body": "当前前端路由尚不支持所请求的能力。", + "workflowActivityVNext.unavailable.description": "此 vNext 页面暂不可用。", + "workflowActivityVNext.unavailable.title": "不可用", + "workflowActivityVNext.workflows.columnActions": "操作", + "workflowActivityVNext.workflows.columnState": "状态", + "workflowActivityVNext.workflows.columnUpdated": "更新时间", + "workflowActivityVNext.workflows.columnWorkflow": "工作流", + "workflowActivityVNext.workflows.committed": "已提交", + "workflowActivityVNext.workflows.committedUnavailable": "已提交工作流 catalogue 不可用", + "workflowActivityVNext.workflows.description": "创建、编辑并运行 scoped 工作流草稿。", + "workflowActivityVNext.workflows.draft": "草稿", + "workflowActivityVNext.workflows.draftsUnavailable": "草稿 catalogue 不可用", + "workflowActivityVNext.workflows.empty": "暂无工作流", + "workflowActivityVNext.workflows.emptyDescription": "创建持久化草稿后即可开始。", + "workflowActivityVNext.workflows.loading": "正在加载工作流", + "workflowActivityVNext.workflows.new": "新建工作流", + "workflowActivityVNext.workflows.noMatch": "没有匹配的工作流", + "workflowActivityVNext.workflows.noMatchDescription": "请尝试其他搜索条件。", + "workflowActivityVNext.workflows.openAria": "打开 {name}", + "workflowActivityVNext.workflows.refreshAria": "刷新工作流", + "workflowActivityVNext.workflows.retryAria": "重试加载工作流", + "workflowActivityVNext.workflows.runFromEditor": "请打开编辑器验证并运行此工作流。", + "workflowActivityVNext.workflows.search": "搜索工作流", + "workflowActivityVNext.workflows.searchAria": "搜索工作流", + "workflowActivityVNext.workflows.title": "工作流", + "workflowActivityVNext.workflows.unavailable": "工作流不可用", + "workflowActivityVNext.workflows.unavailableDescription": "两个权威 catalogue 来源都无法加载。", + "workflowActivityVNext.workflows.activityFilterUnavailable": "活动筛选需要后端返回明确的 definition actor ID。", + "workflowActivityVNext.workflows.activityResolutionFailed": "工作流活动筛选不可用", + "workflowActivityVNext.workflows.viewActivity": "活动", +}; + +export default workflowActivityVNextMessages; diff --git a/apps/aevatar-console-web/src/locales/zh-CN.ts b/apps/aevatar-console-web/src/locales/zh-CN.ts index ce32d183a9..682555a80d 100644 --- a/apps/aevatar-console-web/src/locales/zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/zh-CN.ts @@ -1,7 +1,9 @@ import projectMessages from './projectMessages.zh-CN'; +import workflowActivityVNextMessages from './workflowActivityVNextMessages.zh-CN'; const zhCNMessages = { ...projectMessages, + ...workflowActivityVNextMessages, 'common.appName': 'Aevatar', 'common.language.english': 'English', 'common.language.label': '语言', diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx new file mode 100644 index 0000000000..aa1b0101a1 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx @@ -0,0 +1,98 @@ +import { + ControlOutlined, + HistoryOutlined, + PartitionOutlined, +} from "@ant-design/icons"; +import React from "react"; +import { ConsoleAuthActions, ConsoleLanguageSwitch } from "@/shared/ui/ConsoleHeaderActions"; +import { history } from "@/shared/navigation/history"; +import { t } from "@/shared/i18n/messages"; +import { + buildWorkflowActivitySectionHref, + type WorkflowActivitySection, +} from "./navigation"; +import { workflowActivityVNextCss } from "./styles"; + +type ShellProps = { + readonly activeSection: WorkflowActivitySection; + readonly children: React.ReactNode; + readonly description: string; + readonly headerActions?: React.ReactNode; + readonly onNavigate?: (target: string) => void; + readonly scopeId: string; + readonly title: string; +}; + +const items = [ + { key: "workflows" as const, icon: , labelKey: "workflows", fallback: "Workflows" }, + { key: "activity" as const, icon: , labelKey: "activity", fallback: "Activity" }, + { key: "settings" as const, icon: , labelKey: "settings", fallback: "Settings" }, +]; + +function Navigation({ activeSection, onNavigate, scopeId }: Pick) { + return ( + + ); +} + +const WorkflowActivityVNextShell: React.FC = ({ + activeSection, + children, + description, + headerActions, + onNavigate, + scopeId, + title, +}) => ( +
+ + +
+
+ +
+
+
+

{title}

+

{description}

+
+
+ {headerActions} + + +
+
+
{children}
+
+
+); + +export default WorkflowActivityVNextShell; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.test.tsx new file mode 100644 index 0000000000..ef65616006 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.test.tsx @@ -0,0 +1,104 @@ +import { screen, waitFor } from "@testing-library/react"; +import * as React from "react"; +import { + cleanupTestQueryClients, + renderWithQueryClient, +} from "../../../../tests/reactQueryTestUtils"; +import ActivityPage from "./ActivityPage"; + +let mockSearch = ""; + +jest.mock("@umijs/max", () => ({ + getIntl: () => ({ + formatMessage: ({ defaultMessage, id }: { defaultMessage?: string; id: string }) => + defaultMessage ?? id, + }), + getLocale: () => "en-US", + history: {}, + setLocale: jest.fn(), + useIntl: () => ({ + formatMessage: ({ defaultMessage, id }: { defaultMessage?: string; id: string }) => + defaultMessage ?? id, + }), + useModel: () => ({ initialState: { auth: { authenticated: true } } }), +})); + +jest.mock("@/shared/api/workflowActivityApi", () => { + class WorkflowActivityApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } + } + + return { + WorkflowActivityApiError, + workflowActivityApi: { listRuns: jest.fn() }, + }; +}); + +jest.mock("@/shared/navigation/history", () => ({ + history: { push: jest.fn(), replace: jest.fn() }, +})); + +jest.mock("@/shared/ui/ConsoleHeaderActions", () => ({ + ConsoleAuthActions: () => , + ConsoleLanguageSwitch: () => , +})); + +jest.mock("../hooks/useConsoleLocation", () => ({ + useConsoleLocation: () => ({ + hash: "", + pathname: "/scopes/scope-alpha/workflow-activity-vnext/activity", + search: mockSearch, + }), +})); + +const mockListRuns = jest.requireMock("@/shared/api/workflowActivityApi") + .workflowActivityApi.listRuns as jest.Mock; + +describe("Workflow Activity vNext Activity ledger", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearch = ""; + mockListRuns.mockResolvedValue([]); + }); + + afterEach(() => cleanupTestQueryClients()); + + it("preserves the honest unavailable notice for a workflow without definition identity", async () => { + mockSearch = "?workflowFilter=unavailable"; + + renderWithQueryClient(); + + expect( + await screen.findByText("Workflow filter unavailable; showing unfiltered Activity"), + ).toBeInTheDocument(); + await waitFor(() => + expect(mockListRuns).toHaveBeenCalledWith("scope-alpha", { + status: undefined, + origins: undefined, + definitionActorIds: undefined, + take: 100, + }), + ); + }); + + it("sends only URL-backed supported filters to the observatory API", async () => { + mockSearch = "?status=failed&origin=draft&definition=definition-alpha"; + + renderWithQueryClient(); + + await waitFor(() => + expect(mockListRuns).toHaveBeenCalledWith("scope-alpha", { + status: "failed", + origins: ["draft"], + definitionActorIds: ["definition-alpha"], + take: 100, + }), + ); + expect(screen.getByRole("button", { name: "Clear workflow filter" })).toBeEnabled(); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx new file mode 100644 index 0000000000..20a59ef5e6 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx @@ -0,0 +1,95 @@ +import { ReloadOutlined, SearchOutlined } from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, Button, Input, Select, Space } from "antd"; +import React from "react"; +import { workflowActivityApi, WorkflowActivityApiError } from "@/shared/api/workflowActivityApi"; +import { t } from "@/shared/i18n/messages"; +import { history } from "@/shared/navigation/history"; +import WorkflowActivityVNextShell from "../WorkflowActivityVNextShell"; +import { buildWorkflowActivityRunHref } from "../navigation"; +import { useConsoleLocation } from "../hooks/useConsoleLocation"; + +function formatDate(value: string | null): string { + if (!value) return t("workflowActivityVNext.common.unavailable", "Unavailable"); + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); +} + +function failureTitle(error: unknown): string { + if (error instanceof WorkflowActivityApiError) { + if (error.status === 401) return t("workflowActivityVNext.state.unauthorized", "Authentication required"); + if (error.status === 403) return t("workflowActivityVNext.state.forbidden", "Scope access forbidden"); + } + return t("workflowActivityVNext.activity.unavailable", "Activity unavailable"); +} + +const ActivityPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { + const location = useConsoleLocation(); + const initialParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); + const [status, setStatus] = React.useState(initialParams.get("status") ?? ""); + const [origin, setOrigin] = React.useState(initialParams.get("origin") ?? ""); + const [definition, setDefinition] = React.useState(initialParams.get("definition") ?? ""); + const [workflowFilter, setWorkflowFilter] = React.useState( + initialParams.get("workflowFilter") ?? "", + ); + const [search, setSearch] = React.useState(""); + const runs = useQuery({ + queryKey: ["workflow-activity-vnext", "runs", scopeId, status, origin, definition], + queryFn: () => workflowActivityApi.listRuns(scopeId, { + status: status || undefined, + origins: origin ? [origin] : undefined, + definitionActorIds: definition ? [definition] : undefined, + take: 100, + }), + retry: false, + }); + + React.useEffect(() => { + const params = new URLSearchParams(location.search); + setStatus(params.get("status") ?? ""); + setOrigin(params.get("origin") ?? ""); + setDefinition(params.get("definition") ?? ""); + setWorkflowFilter(params.get("workflowFilter") ?? ""); + }, [location.search]); + + React.useEffect(() => { + const params = new URLSearchParams(); + if (status) params.set("status", status); + if (origin) params.set("origin", origin); + if (definition) params.set("definition", definition); + if (workflowFilter) params.set("workflowFilter", workflowFilter); + const suffix = params.toString(); + history.replace(`${location.pathname}${suffix ? `?${suffix}` : ""}`); + }, [definition, location.pathname, origin, status, workflowFilter]); + + const filtered = (runs.data ?? []).filter((run) => { + const normalized = search.trim().toLowerCase(); + return !normalized || [run.workflowName, run.runId, run.status].some((value) => value.toLowerCase().includes(normalized)); + }); + + return ( + } onClick={() => void runs.refetch()}>{t("workflowActivityVNext.common.refresh", "Refresh")}} scopeId={scopeId} title={t("workflowActivityVNext.activity.title", "Activity")}> + {workflowFilter === "unavailable" ? ( + setWorkflowFilter("")} + showIcon + type="warning" + /> + ) : null} +
+ setSearch(event.target.value)} placeholder={t("workflowActivityVNext.activity.search", "Search observed runs")} prefix={} role="searchbox" style={{ width: 320 }} value={search} /> + + + {definition ? : null} + +
+

{t("workflowActivityVNext.activity.window", "Showing up to 100 recently observed runs. This is not a lifetime total.")}

+ {runs.isPending ?

{t("workflowActivityVNext.activity.loading", "Loading observed runs")}

: runs.isError ?

{failureTitle(runs.error)}

{runs.error instanceof Error ? runs.error.message : String(runs.error)}

: filtered.length === 0 ?

{runs.data?.length ? t("workflowActivityVNext.activity.noMatch", "No matching observed runs") : t("workflowActivityVNext.activity.empty", "No observed runs")}

{t("workflowActivityVNext.activity.emptyDescription", "Activity appears only after the observatory read model returns a run.")}

:
{filtered.map((run) => )}
{t("workflowActivityVNext.activity.columnRun", "Run")}{t("workflowActivityVNext.activity.columnStatus", "Status")}{t("workflowActivityVNext.activity.columnOrigin", "Origin")}{t("workflowActivityVNext.activity.columnUpdated", "Observed update")}{t("workflowActivityVNext.activity.columnVersion", "State version")}
{run.status || t("workflowActivityVNext.common.unknown", "Unknown")}{run.runOrigin || t("workflowActivityVNext.common.unknown", "Unknown")}{formatDate(run.updatedAtUtc)}{run.stateVersion}
} +
+ ); +}; + +export default ActivityPage; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx new file mode 100644 index 0000000000..27b1260f72 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.test.tsx @@ -0,0 +1,237 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import * as React from "react"; +import { + cleanupTestQueryClients, + renderWithQueryClient, +} from "../../../../tests/reactQueryTestUtils"; +import RunDetailPage from "./RunDetailPage"; + +jest.mock("@umijs/max", () => ({ + getIntl: () => ({ + formatMessage: ({ defaultMessage, id }: { defaultMessage?: string; id: string }, values?: Record) => + (defaultMessage ?? id).replace(/\{(\w+)\}/g, (_match: string, key: string) => String(values?.[key] ?? "")), + }), + getLocale: () => "en-US", + history: {}, + setLocale: jest.fn(), + useIntl: () => ({ + formatMessage: ({ defaultMessage, id }: { defaultMessage?: string; id: string }) => + defaultMessage ?? id, + }), + useModel: () => ({ initialState: { auth: { authenticated: true } } }), +})); + +jest.mock("@/shared/api/workflowActivityApi", () => { + class WorkflowActivityApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } + } + + return { + WorkflowActivityApiError, + workflowActivityApi: { + forkRun: jest.fn(), + getRun: jest.fn(), + getRunGraph: jest.fn(), + }, + }; +}); + +jest.mock("@/shared/navigation/history", () => ({ + history: { push: jest.fn(), replace: jest.fn() }, +})); + +jest.mock("@/shared/ui/ConsoleHeaderActions", () => ({ + ConsoleAuthActions: () => , + ConsoleLanguageSwitch: () => , +})); + +const mockWorkflowActivityApi = jest.requireMock("@/shared/api/workflowActivityApi") + .workflowActivityApi as { + forkRun: jest.Mock; + getRun: jest.Mock; + getRunGraph: jest.Mock; +}; + +function buildRunDetail() { + return { + summary: { + runId: "run-source-alpha", + workflowName: "Incident review", + status: "failed", + success: false, + startedAtUtc: "2026-08-04T10:00:00Z", + updatedAtUtc: "2026-08-04T10:01:00Z", + stateVersion: 7, + scopeId: "scope-alpha", + runOrigin: "draft", + }, + input: "Investigate checkout latency", + finalOutput: "", + finalError: "Approval timed out", + diagnostics: [ + { + timestampUtc: "2026-08-04T10:01:00Z", + severity: "error", + code: "APPROVAL_TIMEOUT", + source: "workflow", + message: "Approval did not arrive before the deadline", + hint: "Review the approval channel", + stepId: "step-failed", + stepType: "human_approval", + targetRole: "", + }, + ], + steps: [ + { + stepId: "step-failed", + stepType: "human_approval", + targetRole: "", + requestedAtUtc: "2026-08-04T10:00:00Z", + completedAtUtc: "2026-08-04T10:01:00Z", + success: false, + durationMs: 60000, + outputPreview: "", + error: "Approval timed out", + requestParameters: {}, + nextStepId: "", + branchKey: "", + suspensionType: "approval", + suspensionPrompt: "Approve?", + suspensionContent: "", + suspensionTimeoutSeconds: 60, + toolApproval: null, + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0 }, + }, + { + stepId: "step-root", + stepType: "llm_call", + targetRole: "responder", + requestedAtUtc: "2026-08-04T09:59:00Z", + completedAtUtc: "2026-08-04T10:00:00Z", + success: true, + durationMs: 60000, + outputPreview: "Prepared response", + error: "", + requestParameters: { prompt: "Investigate" }, + nextStepId: "step-failed", + branchKey: "", + suspensionType: "", + suspensionPrompt: "", + suspensionContent: "", + suspensionTimeoutSeconds: null, + toolApproval: null, + usage: { promptTokens: 4, completionTokens: 8, totalTokens: 12, cost: 0.02 }, + }, + ], + timeline: [], + statistics: { + totalSteps: 2, + requestedSteps: 2, + completedSteps: 2, + roleReplyCount: 1, + stepTypeCounts: { human_approval: 1, llm_call: 1 }, + }, + usageTotals: { promptTokens: 4, completionTokens: 8, totalTokens: 12, cost: 0.02 }, + }; +} + +describe("Workflow Activity vNext run detail recovery", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockWorkflowActivityApi.getRun.mockResolvedValue(buildRunDetail()); + mockWorkflowActivityApi.getRunGraph.mockResolvedValue({ + rootNodeId: "node-root", + nodes: [ + { nodeId: "node-failed", nodeType: "step", stepId: "step-failed" }, + { nodeId: "node-root", nodeType: "step", stepId: "step-root" }, + ], + edges: [], + }); + mockWorkflowActivityApi.forkRun.mockResolvedValue({ + accepted: true, + sourceRunId: "run-source-alpha", + newRunActorId: "actor-new-alpha", + workflowName: "Incident review", + acceptedCommandId: "command-alpha", + correlationId: "correlation-alpha", + statusUrl: "/api/workflow/runs/status/command-alpha", + }); + }); + + afterEach(() => cleanupTestQueryClients()); + + it("confirms the immutable source and explicit step before submitting a retry", async () => { + renderWithQueryClient(); + + fireEvent.click(await screen.findByRole("button", { name: "Retry failed step" })); + expect(mockWorkflowActivityApi.forkRun).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "Confirm new run" })).toBeInTheDocument(); + expect(screen.getAllByText("run-source-alpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("step-failed").length).toBeGreaterThan(0); + expect(screen.getAllByText("Investigate checkout latency").length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: "Confirm retry" })); + + await waitFor(() => + expect(mockWorkflowActivityApi.forkRun).toHaveBeenCalledWith({ + sourceRunId: "run-source-alpha", + startAtStepId: "step-failed", + input: "Investigate checkout latency", + }), + ); + expect(await screen.findByText("actor-new-alpha")).toBeInTheDocument(); + expect(screen.getByText("command-alpha")).toBeInTheDocument(); + expect(screen.getByText("correlation-alpha")).toBeInTheDocument(); + expect(screen.getByText("/api/workflow/runs/status/command-alpha")).toBeInTheDocument(); + expect(screen.getAllByText("run-source-alpha").length).toBeGreaterThan(0); + }); + + it("keeps committed detail visible and disables run again when graph evidence fails", async () => { + mockWorkflowActivityApi.getRunGraph.mockRejectedValue(new Error("graph offline")); + + renderWithQueryClient(); + + expect(await screen.findByText("Incident review")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Graph" })); + expect(await screen.findByText("Run graph unavailable; committed detail remains visible")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Run again" })).toBeDisabled(); + }); + + it("names a forbidden detail response without inventing run facts", async () => { + const { WorkflowActivityApiError } = jest.requireMock( + "@/shared/api/workflowActivityApi", + ); + mockWorkflowActivityApi.getRun.mockRejectedValue( + new WorkflowActivityApiError("Access denied", 403), + ); + + renderWithQueryClient(); + + expect( + await screen.findByRole("heading", { + level: 2, + name: "Scope access forbidden", + }), + ).toBeInTheDocument(); + expect(screen.getByText("Access denied")).toBeInTheDocument(); + expect(screen.queryByText("Incident review")).not.toBeInTheDocument(); + }); + + it("renders returned diagnostics, request parameters, statistics, and usage facts", async () => { + renderWithQueryClient(); + + expect(await screen.findByText(/"prompt": "Investigate"/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: "Diagnostics" })); + expect(screen.getByText("APPROVAL_TIMEOUT")).toBeInTheDocument(); + expect(screen.getByText("Approval did not arrive before the deadline")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Statistics and usage" })); + expect(screen.getByText("12")).toBeInTheDocument(); + expect(screen.getByText("0.02")).toBeInTheDocument(); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx new file mode 100644 index 0000000000..851be82682 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/RunDetailPage.tsx @@ -0,0 +1,120 @@ +import { ArrowLeftOutlined, ReloadOutlined } from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, Button, Descriptions, Modal, Space, Tabs } from "antd"; +import React from "react"; +import { + workflowActivityApi, + WorkflowActivityApiError, +} from "@/shared/api/workflowActivityApi"; +import { t } from "@/shared/i18n/messages"; +import { history } from "@/shared/navigation/history"; +import type { WorkflowRunForkAcceptedReceipt } from "@/shared/models/workflowActivity"; +import WorkflowActivityVNextShell from "../WorkflowActivityVNextShell"; +import { buildWorkflowActivitySectionHref } from "../navigation"; +import { resolveRunRecovery } from "./runRecovery"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function failureTitle(error: unknown): string { + if (error instanceof WorkflowActivityApiError) { + if (error.status === 401) { + return t("workflowActivityVNext.state.unauthorized", "Authentication required"); + } + if (error.status === 403) { + return t("workflowActivityVNext.state.forbidden", "Scope access forbidden"); + } + } + return t("workflowActivityVNext.run.unavailable", "Run unavailable"); +} + +const RunDetailPage: React.FC<{ readonly runId: string; readonly scopeId: string }> = ({ runId, scopeId }) => { + const detail = useQuery({ queryKey: ["workflow-activity-vnext", "run-detail", scopeId, runId], queryFn: () => workflowActivityApi.getRun(scopeId, runId), retry: false }); + const graph = useQuery({ queryKey: ["workflow-activity-vnext", "run-graph", scopeId, runId], queryFn: () => workflowActivityApi.getRunGraph(scopeId, runId), retry: false }); + const [forking, setForking] = React.useState(false); + const [forkError, setForkError] = React.useState(""); + const [receipt, setReceipt] = React.useState(null); + const [pendingRecovery, setPendingRecovery] = React.useState<{ + readonly kind: "retry" | "run_again"; + readonly stepId: string; + } | null>(null); + + const recovery = resolveRunRecovery(detail.data?.steps ?? [], graph.data); + const fork = async (startAtStepId: string): Promise => { + if (forking) return false; + setForking(true); + setForkError(""); + try { + setReceipt(await workflowActivityApi.forkRun({ sourceRunId: runId, startAtStepId, input: detail.data?.input })); + return true; + } catch (error) { + setForkError(errorMessage(error)); + return false; + } finally { + setForking(false); + } + }; + + const confirmFork = async () => { + if (!pendingRecovery) return; + if (await fork(pendingRecovery.stepId)) setPendingRecovery(null); + }; + + if (detail.isPending) return

{t("workflowActivityVNext.run.loading", "Loading run")}

; + if (detail.isError || !detail.data) return

{failureTitle(detail.error)}

{errorMessage(detail.error)}

; + + const run = detail.data; + return ( + } scopeId={scopeId} title={run.summary.workflowName || run.summary.runId}> +
{run.summary.status}{run.summary.runId}{t("workflowActivityVNext.run.stateVersion", "State version {version}", { version: run.summary.stateVersion })}
+ {forkError ? : null} + {receipt ? history.push(buildWorkflowActivitySectionHref(scopeId, "activity"))}>{t("workflowActivityVNext.editor.openActivity", "Open Activity")}} description={{receipt.newRunActorId} }, { key: "command", label: t("workflowActivityVNext.run.commandId", "Command ID"), children: {receipt.acceptedCommandId} }, { key: "correlation", label: t("workflowActivityVNext.run.correlationId", "Correlation ID"), children: {receipt.correlationId} }, { key: "status", label: t("workflowActivityVNext.run.statusUrl", "Observation status URL"), children: {receipt.statusUrl} }]} />} message={t("workflowActivityVNext.run.forkAccepted", "New run accepted; waiting for independent Activity observation")} showIcon type="info" /> : null} + + {run.steps.map((step) => )}
{t("workflowActivityVNext.run.step", "Step")}{t("workflowActivityVNext.run.type", "Type")}{t("workflowActivityVNext.activity.columnStatus", "Status")}{t("workflowActivityVNext.run.output", "Output")}{t("workflowActivityVNext.run.requestParameters", "Request parameters")}
{step.stepId}{step.stepType}{step.success === null ? t("workflowActivityVNext.common.pending", "Pending") : step.success ? t("workflowActivityVNext.common.succeeded", "Succeeded") : t("workflowActivityVNext.common.failed", "Failed")}{step.error || step.outputPreview || t("workflowActivityVNext.common.unavailable", "Unavailable")}
{Object.keys(step.requestParameters).length ? JSON.stringify(step.requestParameters, null, 2) : t("workflowActivityVNext.common.empty", "Empty")}
:

{t("workflowActivityVNext.run.noSteps", "No committed steps are visible yet.")}

, + }, + { + key: "diagnostics", + label: t("workflowActivityVNext.run.diagnostics", "Diagnostics"), + children: run.diagnostics.length ?
{run.diagnostics.map((diagnostic) => )}
{t("workflowActivityVNext.run.severity", "Severity")}{t("workflowActivityVNext.run.code", "Code")}{t("workflowActivityVNext.run.step", "Step")}{t("workflowActivityVNext.run.message", "Message")}
{diagnostic.severity}{diagnostic.code}{diagnostic.stepId || t("workflowActivityVNext.common.unavailable", "Unavailable")}{diagnostic.message}{diagnostic.hint ? {diagnostic.hint} : null}
:

{t("workflowActivityVNext.run.noDiagnostics", "No diagnostics were returned.")}

, + }, + { + key: "timeline", + label: t("workflowActivityVNext.run.timeline", "Timeline"), + children: run.timeline.length ?
    {run.timeline.map((event) =>
  1. {event.timestampUtc} · {event.kind} · {event.message}{event.content ?
    {event.content}
    : null}{event.toolCall ?
    {JSON.stringify(event.toolCall, null, 2)}
    : null}
  2. )}
:

{t("workflowActivityVNext.run.noTimeline", "No timeline events are visible yet.")}

, + }, + { + key: "statistics", + label: t("workflowActivityVNext.run.statisticsUsage", "Statistics and usage"), + children: , + }, + { + key: "graph", + label: t("workflowActivityVNext.run.graph", "Graph"), + children: graph.isPending ?

{t("workflowActivityVNext.run.graphLoading", "Loading run graph")}

: graph.isError ? void graph.refetch()}>{t("workflowActivityVNext.common.retry", "Retry")}} message={t("workflowActivityVNext.run.graphUnavailable", "Run graph unavailable; committed detail remains visible") } showIcon type="warning" /> :

{t("workflowActivityVNext.run.graphSummary", "{nodes} nodes · {edges} edges", { nodes: graph.data?.nodes.length ?? 0, edges: graph.data?.edges.length ?? 0 })}

    {graph.data?.nodes.map((node) =>
  • {node.nodeId}{node.stepId ? ` · ${node.stepId}` : ""}
  • )}
, + }, + ]} /> + !forking && setPendingRecovery(null)} + onOk={() => void confirmFork()} + open={Boolean(pendingRecovery)} + title={t("workflowActivityVNext.run.confirmTitle", "Confirm new run")} + > + + {run.summary.runId} }, { key: "step", label: t("workflowActivityVNext.run.startStepId", "Start step ID"), children: {pendingRecovery?.stepId} }, { key: "input", label: t("workflowActivityVNext.run.input", "Input"), children: run.input || t("workflowActivityVNext.common.empty", "Empty") }]} /> + {forkError ? : null} + +
+ ); +}; + +export default RunDetailPage; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts new file mode 100644 index 0000000000..43858d4389 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.test.ts @@ -0,0 +1,51 @@ +import { resolveRunRecovery } from "./runRecovery"; + +describe("resolveRunRecovery", () => { + it("enables retry only for one explicit failed step and run again for an explicit graph root step", () => { + expect( + resolveRunRecovery( + [ + { stepId: "step-second", success: false }, + { stepId: "step-first", success: true }, + ], + { + rootNodeId: "node-root", + nodes: [ + { nodeId: "node-root", stepId: "step-first" }, + { nodeId: "node-second", stepId: "step-second" }, + ], + }, + ), + ).toEqual({ retryStepId: "step-second", runAgainStepId: "step-first" }); + }); + + it("does not guess a failed step or first step from array order", () => { + expect( + resolveRunRecovery([ + { stepId: "step-first", success: false }, + { stepId: "step-second", success: false }, + ]), + ).toEqual({ retryStepId: null, runAgainStepId: null }); + }); + + it("does not enable run again when the graph root is missing or lacks an explicit step id", () => { + const steps = [{ stepId: "step-first", success: true }] as const; + + expect( + resolveRunRecovery(steps, { + rootNodeId: "node-missing", + nodes: [{ nodeId: "node-first", stepId: "step-first" }], + }), + ).toEqual({ retryStepId: null, runAgainStepId: null }); + expect( + resolveRunRecovery(steps, { + rootNodeId: "node-root", + nodes: [{ nodeId: "node-root", stepId: "" }], + }), + ).toEqual({ retryStepId: null, runAgainStepId: null }); + }); + + it("does not invent a first step for an empty run", () => { + expect(resolveRunRecovery([])).toEqual({ retryStepId: null, runAgainStepId: null }); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts new file mode 100644 index 0000000000..1c94e79bfc --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/runRecovery.ts @@ -0,0 +1,33 @@ +export type RecoveryStepCandidate = { + readonly stepId: string; + readonly success: boolean | null; +}; + +export type RecoveryGraphCandidate = { + readonly nodes: readonly { + readonly nodeId: string; + readonly stepId: string; + }[]; + readonly rootNodeId: string; +}; + +export function resolveRunRecovery( + steps: readonly RecoveryStepCandidate[], + graph?: RecoveryGraphCandidate, +): { + readonly retryStepId: string | null; + readonly runAgainStepId: string | null; +} { + const failed = steps.filter((step) => step.success === false && step.stepId.trim()); + const rootNodeId = graph?.rootNodeId.trim() ?? ""; + const rootStepId = rootNodeId + ? graph?.nodes.find((node) => node.nodeId.trim() === rootNodeId)?.stepId.trim() ?? "" + : ""; + const explicitRootStepId = rootStepId && steps.some((step) => step.stepId.trim() === rootStepId) + ? rootStepId + : null; + return { + retryStepId: failed.length === 1 ? failed[0].stepId : null, + runAgainStepId: explicitRootStepId, + }; +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useConsoleLocation.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useConsoleLocation.ts new file mode 100644 index 0000000000..d12e85c5a2 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useConsoleLocation.ts @@ -0,0 +1,18 @@ +import React from "react"; +import { + getLocationSnapshot, + subscribeToLocationChanges, +} from "@/shared/navigation/history"; + +export function useConsoleLocation(): { + readonly pathname: string; + readonly search: string; +} { + const snapshot = React.useSyncExternalStore( + subscribeToLocationChanges, + getLocationSnapshot, + () => "", + ); + const url = new URL(snapshot || "/", "http://console.local"); + return { pathname: url.pathname, search: url.search }; +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.test.ts new file mode 100644 index 0000000000..5e3e15b164 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.test.ts @@ -0,0 +1,62 @@ +import { observeDraftMaterialization } from "./useDraftMaterialization"; + +describe("observeDraftMaterialization", () => { + it("treats bounded 404 as projection delay and keeps observing the exact receipt id", async () => { + const pending = Object.assign(new Error("pending"), { status: 404 }); + const read = jest + .fn() + .mockRejectedValueOnce(pending) + .mockRejectedValueOnce(pending) + .mockResolvedValueOnce({ workflowId: "wf-api-returned" }); + + await expect( + observeDraftMaterialization({ + workflowId: "wf-api-returned", + read, + isNotFound: (error) => error === pending, + wait: async () => undefined, + delaysMs: [0, 0, 0], + }), + ).resolves.toEqual({ kind: "readable", workflow: { workflowId: "wf-api-returned" } }); + expect(read.mock.calls).toEqual([ + ["wf-api-returned"], + ["wf-api-returned"], + ["wf-api-returned"], + ]); + }); + + it("returns delayed without inventing success and retry reads the same id", async () => { + const pending = Object.assign(new Error("pending"), { status: 404 }); + const read = jest.fn().mockRejectedValue(pending); + const input = { + workflowId: "wf-api-returned", + read, + isNotFound: (error: unknown) => error === pending, + wait: async () => undefined, + delaysMs: [0, 0], + }; + + await expect(observeDraftMaterialization(input)).resolves.toEqual({ kind: "delayed" }); + await expect(observeDraftMaterialization(input)).resolves.toEqual({ kind: "delayed" }); + expect(read).toHaveBeenCalledTimes(4); + expect(new Set(read.mock.calls.map(([workflowId]) => workflowId))).toEqual( + new Set(["wf-api-returned"]), + ); + }); + + it("surfaces non-404 failures immediately", async () => { + const forbidden = Object.assign(new Error("forbidden"), { status: 403 }); + const read = jest.fn().mockRejectedValue(forbidden); + + await expect( + observeDraftMaterialization({ + workflowId: "wf-api-returned", + read, + isNotFound: () => false, + wait: async () => undefined, + delaysMs: [0, 0], + }), + ).rejects.toBe(forbidden); + expect(read).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.ts new file mode 100644 index 0000000000..4cb0ea614a --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useDraftMaterialization.ts @@ -0,0 +1,101 @@ +import React from "react"; +import { isStudioApiStatus, studioApi } from "@/shared/studio/api"; +import type { + StudioWorkflowDraftCreateAcceptedReceipt, + StudioWorkflowFile, +} from "@/shared/studio/models"; + +export const DRAFT_MATERIALIZATION_DELAYS_MS = [0, 300, 700, 1200, 2000, 3000, 5000] as const; + +type ObservationInput = { + readonly delaysMs?: readonly number[]; + readonly isNotFound: (error: unknown) => boolean; + readonly read: (workflowId: string) => Promise; + readonly wait?: (delayMs: number) => Promise; + readonly workflowId: string; +}; + +export type DraftMaterializationResult = + | { readonly kind: "readable"; readonly workflow: T } + | { readonly kind: "delayed" }; + +function defaultWait(delayMs: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, delayMs)); +} + +export async function observeDraftMaterialization( + input: ObservationInput, +): Promise> { + const delays = input.delaysMs ?? DRAFT_MATERIALIZATION_DELAYS_MS; + const wait = input.wait ?? defaultWait; + + for (const delayMs of delays) { + if (delayMs > 0) await wait(delayMs); + try { + return { kind: "readable", workflow: await input.read(input.workflowId) }; + } catch (error) { + if (!input.isNotFound(error)) throw error; + } + } + + return { kind: "delayed" }; +} + +export type DraftMaterializationPhase = + | "idle" + | "accepted" + | "observing" + | "delayed" + | "readable" + | "failed"; + +export function useDraftMaterialization(scopeId: string) { + const [phase, setPhase] = React.useState("idle"); + const [receipt, setReceipt] = React.useState(null); + const [error, setError] = React.useState(null); + const generationRef = React.useRef(0); + + const observe = React.useCallback( + async (nextReceipt: StudioWorkflowDraftCreateAcceptedReceipt): Promise => { + const generation = ++generationRef.current; + setReceipt(nextReceipt); + setError(null); + setPhase("accepted"); + await Promise.resolve(); + if (generation !== generationRef.current) return null; + setPhase("observing"); + + try { + const result = await observeDraftMaterialization({ + workflowId: nextReceipt.workflowId, + read: (workflowId) => studioApi.getWorkflowDraftFile(workflowId, scopeId), + isNotFound: (candidate) => isStudioApiStatus(candidate, 404), + }); + if (generation !== generationRef.current) return null; + if (result.kind === "delayed") { + setPhase("delayed"); + return null; + } + setPhase("readable"); + return result.workflow; + } catch (candidate) { + if (generation !== generationRef.current) return null; + setError(candidate); + setPhase("failed"); + return null; + } + }, + [scopeId], + ); + + const retry = React.useCallback( + () => (receipt ? observe(receipt) : Promise.resolve(null)), + [observe, receipt], + ); + + React.useEffect(() => () => { + generationRef.current += 1; + }, []); + + return { error, observe, phase, receipt, retry } as const; +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.ts new file mode 100644 index 0000000000..153b7f2168 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useRunObservation.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { workflowActivityApi } from "@/shared/api/workflowActivityApi"; + +export function useRunObservation(scopeId: string, runId: string) { + return useQuery({ + enabled: Boolean(scopeId.trim() && runId.trim()), + queryKey: ["workflow-activity-vnext", "run", scopeId, runId], + queryFn: () => workflowActivityApi.getRun(scopeId, runId), + retry: false, + }); +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowEditor.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowEditor.ts new file mode 100644 index 0000000000..5430da33d2 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowEditor.ts @@ -0,0 +1,287 @@ +import { useQuery } from "@tanstack/react-query"; +import React from "react"; +import { parseBackendSSEStream } from "@/shared/agui/sseFrameNormalizer"; +import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; +import { history } from "@/shared/navigation/history"; +import { + applyStepInspectorDraft, + createStepInspectorDraft, + insertStepByType, +} from "@/shared/studio/document"; +import { buildStudioGraphElements, buildStudioWorkflowLayout } from "@/shared/studio/graph"; +import { studioApi } from "@/shared/studio/api"; +import type { + StudioValidationFinding, + StudioWorkflowDocument, + StudioWorkflowFile, +} from "@/shared/studio/models"; +import { buildWorkflowActivityEditorHref } from "../navigation"; +import { hasBlockingFindings } from "../workflows/workflowCreation"; +import { useDraftMaterialization } from "./useDraftMaterialization"; + +export type DraftRunPhase = "idle" | "submitting" | "accepted" | "stream_ended" | "failed"; + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { + const source = useQuery({ + queryKey: ["workflow-activity-vnext", "workflow", scopeId, routeWorkflowId], + queryFn: () => studioApi.getWorkflow(routeWorkflowId, scopeId), + retry: false, + }); + const workspace = useQuery({ + queryKey: ["workflow-activity-vnext", "workspace", scopeId], + queryFn: () => studioApi.getWorkspaceSettings(scopeId), + retry: false, + }); + const materialization = useDraftMaterialization(scopeId); + const [workflow, setWorkflow] = React.useState(null); + const [workflowTitle, setWorkflowTitle] = React.useState(""); + const [yaml, setYaml] = React.useState(""); + const [document, setDocument] = React.useState(null); + const [layout, setLayout] = React.useState(null); + const [findings, setFindings] = React.useState([]); + const [dirty, setDirty] = React.useState(false); + const [saving, setSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(""); + const [runInput, setRunInput] = React.useState(""); + const [runPhase, setRunPhase] = React.useState("idle"); + const [runError, setRunError] = React.useState(""); + const [runEventCount, setRunEventCount] = React.useState(0); + const [selectedNodeId, setSelectedNodeId] = React.useState(""); + const [selectedStepConfigurationError, setSelectedStepConfigurationError] = React.useState(""); + const runControllerRef = React.useRef(null); + const configurationGenerationRef = React.useRef(0); + const loadedSignatureRef = React.useRef(""); + + React.useEffect(() => { + if (!source.data) return; + const signature = `${source.data.workflowId}\u0000${source.data.updatedAtUtc}\u0000${source.data.yaml}`; + if (loadedSignatureRef.current === signature) return; + loadedSignatureRef.current = signature; + setWorkflow(source.data); + setWorkflowTitle(source.data.name); + setYaml(source.data.yaml); + setDocument(source.data.document ?? null); + setLayout(source.data.layout ?? null); + setFindings(source.data.findings); + setDirty(false); + setSaveError(""); + setSelectedNodeId(""); + setSelectedStepConfigurationError(""); + }, [source.data]); + + React.useEffect(() => { + const warn = (event: BeforeUnloadEvent) => { + if (!dirty) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [dirty]); + + React.useEffect(() => () => runControllerRef.current?.abort(), []); + + const updateYaml = (next: string) => { + setYaml(next); + setDirty(true); + }; + + const updateTitle = (next: string) => { + setWorkflowTitle(next); + setDirty(true); + }; + + const parseCurrentYaml = React.useCallback(async () => { + const parsed = await studioApi.parseYaml({ yaml }); + setFindings(parsed.findings); + if (hasBlockingFindings(parsed.document, parsed.findings)) return null; + setDocument(parsed.document ?? null); + return parsed.document ?? null; + }, [yaml]); + + const save = React.useCallback(async () => { + if (!workflow || saving || !workflowTitle.trim()) return false; + setSaving(true); + setSaveError(""); + try { + const parsedDocument = await parseCurrentYaml(); + if (!parsedDocument) return false; + const serialized = await studioApi.serializeYaml({ document: parsedDocument }); + setFindings(serialized.findings); + if (hasBlockingFindings(serialized.document, serialized.findings)) return false; + const directoryId = workflow.directoryId || workspace.data?.directories[0]?.directoryId || ""; + if (!directoryId) throw new Error("No server workflow directory is available for saving this draft."); + const graph = buildStudioGraphElements(serialized.document, layout); + const result = await studioApi.saveWorkflow({ + directoryId, + draftExists: workflow.draftExists, + fileName: workflow.fileName, + layout: buildStudioWorkflowLayout(workflowTitle, graph.nodes, layout), + scopeId, + workflowId: routeWorkflowId, + workflowName: workflowTitle, + yaml: serialized.yaml, + }); + let saved: StudioWorkflowFile | null = null; + if (result.kind === "materialized") saved = result.workflow; + else saved = await materialization.observe(result.receipt); + if (!saved) return false; + setWorkflow(saved); + setYaml(saved.yaml); + setDocument(saved.document ?? serialized.document); + setLayout(saved.layout ?? layout); + setFindings(saved.findings); + setDirty(false); + if (saved.workflowId !== routeWorkflowId) { + history.replace(buildWorkflowActivityEditorHref(scopeId, saved.workflowId)); + } + return true; + } catch (error) { + setSaveError(toErrorMessage(error)); + return false; + } finally { + setSaving(false); + } + }, [layout, materialization.observe, parseCurrentYaml, routeWorkflowId, saving, scopeId, workflow, workflowTitle, workspace.data]); + + const addNode = React.useCallback(async (stepType: string) => { + const current = document ?? (await parseCurrentYaml()); + if (!current) return; + const inserted = insertStepByType(current, stepType); + const serialized = await studioApi.serializeYaml({ document: inserted.document }); + setDocument(serialized.document); + setYaml(serialized.yaml); + setFindings(serialized.findings); + setSelectedNodeId(inserted.nodeId); + setDirty(true); + }, [document, parseCurrentYaml]); + + const graph = React.useMemo( + () => buildStudioGraphElements(document, layout), + [document, layout], + ); + const selectedStepDraft = React.useMemo(() => { + const selectedStepId = selectedNodeId.startsWith("step:") + ? selectedNodeId.slice("step:".length).trim() + : selectedNodeId.trim(); + const selectedStep = graph.steps.find((step) => step.id === selectedStepId); + return selectedStep ? createStepInspectorDraft(selectedStep) : null; + }, [graph.steps, selectedNodeId]); + + const updateSelectedStepConfiguration = React.useCallback( + async (parametersText: string) => { + if (!document || !selectedStepDraft) return; + const generation = ++configurationGenerationRef.current; + try { + const updated = applyStepInspectorDraft( + document, + selectedStepDraft.id, + { ...selectedStepDraft, parametersText }, + ); + setDocument(updated.document); + setSelectedNodeId(updated.nodeId); + setSelectedStepConfigurationError(""); + setDirty(true); + const serialized = await studioApi.serializeYaml({ document: updated.document }); + if (generation !== configurationGenerationRef.current) return; + setDocument(serialized.document); + setYaml(serialized.yaml); + setFindings(serialized.findings); + } catch (error) { + if (generation === configurationGenerationRef.current) { + setSelectedStepConfigurationError(toErrorMessage(error)); + } + } + }, + [document, selectedStepDraft], + ); + + const canRun = Boolean( + document?.steps?.length && + !hasBlockingFindings(document, findings) && + !selectedStepConfigurationError, + ); + + const run = React.useCallback(async () => { + if (runPhase === "submitting" || runPhase === "accepted") return; + setRunPhase("submitting"); + setRunError(""); + setRunEventCount(0); + const controller = new AbortController(); + runControllerRef.current?.abort(); + runControllerRef.current = controller; + try { + const current = await parseCurrentYaml(); + if (!current) { + setRunPhase("failed"); + return; + } + const serialized = await studioApi.serializeYaml({ document: current }); + setFindings(serialized.findings); + if (hasBlockingFindings(serialized.document, serialized.findings)) { + setRunPhase("failed"); + return; + } + const response = await runtimeRunsApi.streamDraftRun( + scopeId, + { prompt: runInput, workflowYamls: [serialized.yaml] }, + controller.signal, + ); + setRunPhase("accepted"); + for await (const _event of parseBackendSSEStream(response, { signal: controller.signal })) { + setRunEventCount((count) => count + 1); + } + if (!controller.signal.aborted) setRunPhase("stream_ended"); + } catch (error) { + if (!controller.signal.aborted) { + setRunError(toErrorMessage(error)); + setRunPhase("failed"); + } + } finally { + if (runControllerRef.current === controller) runControllerRef.current = null; + } + }, [parseCurrentYaml, runInput, runPhase, scopeId]); + + return { + addNode, + canRun, + dirty, + document, + findings, + graph, + loading: source.isPending, + loadError: source.error, + materialization, + run, + runError, + runEventCount, + runInput, + runPhase, + save, + saveError, + saving, + selectedNodeId, + selectedStepConfigurationError, + selectedStepDraft, + selectCanvas: () => { + setSelectedNodeId(""); + setSelectedStepConfigurationError(""); + }, + selectNode: (nodeId: string) => { + setSelectedNodeId(nodeId); + setSelectedStepConfigurationError(""); + }, + setRunInput, + setSelectedStepConfigurationError, + updateSelectedStepConfiguration, + updateTitle, + updateYaml, + workflow, + workflowTitle, + yaml, + } as const; +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx new file mode 100644 index 0000000000..dff7a92ca3 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx @@ -0,0 +1,623 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import * as React from "react"; +import { history } from "@/shared/navigation/history"; +import { + cleanupTestQueryClients, + renderWithQueryClient, +} from "../../../tests/reactQueryTestUtils"; +import WorkflowActivityVNextPage from "./index"; + +let mockPathname = + "/scopes/scope-alpha/workflow-activity-vnext/workflows"; + +jest.mock("@umijs/max", () => ({ + getIntl: () => ({ + formatMessage: ({ defaultMessage, id }: { defaultMessage?: string; id: string }, values?: Record) => + (defaultMessage ?? id).replace(/\{(\w+)\}/g, (_match: string, key: string) => String(values?.[key] ?? "")), + }), + getLocale: () => "en-US", + history: {}, + setLocale: jest.fn(), + useIntl: () => ({ + formatMessage: ({ defaultMessage, id }: { defaultMessage?: string; id: string }) => + defaultMessage ?? id, + }), + useLocation: () => ({ hash: "", pathname: mockPathname, search: "" }), + useModel: () => ({ initialState: { auth: { authenticated: true } } }), + useParams: () => ({ scopeId: "scope-alpha" }), +})); + +jest.mock("@/shared/studio/api", () => ({ + isStudioApiStatus: (error: unknown, status: number) => + Boolean(error && typeof error === "object" && "status" in error && error.status === status), + studioApi: { + authorWorkflow: jest.fn(), + createWorkflowDraft: jest.fn(), + getWorkspaceSettings: jest.fn(), + getAuthSession: jest.fn(), + getUserConfigRuntime: jest.fn(), + getUserLlmSettings: jest.fn(), + getWorkflow: jest.fn(), + getWorkflowDraftFile: jest.fn(), + listWorkflowDrafts: jest.fn(), + parseYaml: jest.fn(), + saveWorkflow: jest.fn(), + saveUserLlmSettings: jest.fn(), + serializeYaml: jest.fn(), + }, +})); + +jest.mock("@/shared/api/scopesApi", () => ({ + scopesApi: { + getWorkflowDetail: jest.fn(), + listWorkflows: jest.fn(), + }, +})); + +jest.mock("@/shared/navigation/history", () => ({ + getLocationSnapshot: () => mockPathname, + history: { push: jest.fn(), replace: jest.fn() }, + subscribeToLocationChanges: () => () => undefined, +})); + +jest.mock("@/shared/ui/ConsoleHeaderActions", () => ({ + ConsoleAuthActions: () => , + ConsoleLanguageSwitch: () => , +})); + +jest.mock("@/pages/team-member-workflow-studio/components/WorkflowStudioCanvas", () => ({ + __esModule: true, + default: ({ + nodes, + onNodeSelect, + }: { + nodes: readonly { readonly id: string }[]; + onNodeSelect?: (nodeId: string) => void; + }) => ( +
+ {nodes.map((node) => ( + + ))} +
+ ), +})); + +jest.mock("@/pages/team-member-workflow-studio/components/WorkflowStudioNodeDetailPanel", () => ({ + __esModule: true, + default: ({ + onConfigurationChange, + stepDraft, + }: { + onConfigurationChange: (parametersText: string) => void; + stepDraft: { readonly id: string } | null; + }) => stepDraft ? ( +
+ Configuring {stepDraft.id} + +
+ ) : null, +})); + +const mockStudioApi = jest.requireMock("@/shared/studio/api").studioApi as { + authorWorkflow: jest.Mock; + createWorkflowDraft: jest.Mock; + getWorkspaceSettings: jest.Mock; + getAuthSession: jest.Mock; + getUserConfigRuntime: jest.Mock; + getUserLlmSettings: jest.Mock; + getWorkflow: jest.Mock; + getWorkflowDraftFile: jest.Mock; + listWorkflowDrafts: jest.Mock; + parseYaml: jest.Mock; + saveWorkflow: jest.Mock; + saveUserLlmSettings: jest.Mock; + serializeYaml: jest.Mock; +}; +const mockScopesApi = jest.requireMock("@/shared/api/scopesApi").scopesApi as { + getWorkflowDetail: jest.Mock; + listWorkflows: jest.Mock; +}; + +describe("Workflow Activity vNext catalogue", () => { + beforeEach(() => { + mockPathname = "/scopes/scope-alpha/workflow-activity-vnext/workflows"; + jest.clearAllMocks(); + }); + + afterEach(() => cleanupTestQueryClients()); + + it("renders authoritative draft and committed rows, searches, and navigates by the real workflow id", async () => { + mockStudioApi.listWorkflowDrafts.mockResolvedValue([ + { + workflowId: "wf-draft-alpha", + name: "Support triage", + description: "Route support requests", + fileName: "support.yaml", + filePath: "/support.yaml", + directoryId: "directory-alpha", + directoryLabel: "Workflows", + stepCount: 3, + hasLayout: true, + updatedAtUtc: "2026-08-04T10:00:00Z", + }, + ]); + mockScopesApi.listWorkflows.mockResolvedValue([ + { + scopeId: "scope-alpha", + workflowId: "wf-committed-beta", + displayName: "Invoice review", + serviceKey: "", + workflowName: "invoice_review", + actorId: "summary-actor-beta", + activeRevisionId: "revision-beta", + deploymentId: "deployment-beta", + deploymentStatus: "active", + updatedAt: "2026-08-03T10:00:00Z", + }, + ]); + mockScopesApi.getWorkflowDetail.mockResolvedValue({ + available: true, + scopeId: "scope-alpha", + workflow: null, + source: { + workflowYaml: "", + definitionActorId: "definition-beta", + inlineWorkflowYamls: null, + }, + }); + + renderWithQueryClient(); + + expect(screen.getByText("Loading workflows")).toBeInTheDocument(); + expect(await screen.findByText("Support triage")).toBeInTheDocument(); + expect(screen.getByText("Invoice review")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Search workflows" }), { + target: { value: "invoice" }, + }); + expect(screen.queryByText("Support triage")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Activity" })); + await waitFor(() => { + expect(mockScopesApi.getWorkflowDetail).toHaveBeenCalledWith( + "scope-alpha", + "wf-committed-beta", + ); + expect(history.push).toHaveBeenCalledWith( + "/scopes/scope-alpha/workflow-activity-vnext/activity?definition=definition-beta", + ); + }); + + fireEvent.click(screen.getByRole("button", { name: "Open Invoice review" })); + expect(history.push).toHaveBeenCalledWith( + "/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-committed-beta", + ); + }); + + it("opens unfiltered Activity with an unavailable notice for a draft-only row", async () => { + mockStudioApi.listWorkflowDrafts.mockResolvedValue([ + { + workflowId: "wf-draft-alpha", + name: "Support triage", + description: "Route support requests", + fileName: "support.yaml", + filePath: "/support.yaml", + directoryId: "directory-alpha", + directoryLabel: "Workflows", + stepCount: 3, + hasLayout: true, + updatedAtUtc: "2026-08-04T10:00:00Z", + }, + ]); + mockScopesApi.listWorkflows.mockResolvedValue([]); + + renderWithQueryClient(); + + expect(await screen.findByText("Support triage")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Activity" })); + expect(mockScopesApi.getWorkflowDetail).not.toHaveBeenCalled(); + expect(history.push).toHaveBeenCalledWith( + "/scopes/scope-alpha/workflow-activity-vnext/activity?workflowFilter=unavailable", + ); + }); + + it("keeps successful rows and names the failed source", async () => { + mockStudioApi.listWorkflowDrafts.mockRejectedValue(new Error("draft source down")); + mockScopesApi.listWorkflows.mockResolvedValue([ + { + scopeId: "scope-alpha", + workflowId: "wf-committed-beta", + displayName: "Invoice review", + serviceKey: "", + workflowName: "invoice_review", + actorId: "definition-beta", + activeRevisionId: "revision-beta", + deploymentId: "deployment-beta", + deploymentStatus: "active", + updatedAt: "2026-08-03T10:00:00Z", + }, + ]); + + renderWithQueryClient(); + + expect(await screen.findByText("Invoice review")).toBeInTheDocument(); + expect(screen.getByText("Draft catalogue unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No workflows yet")).not.toBeInTheDocument(); + }); + + it("renders a successful empty result", async () => { + mockStudioApi.listWorkflowDrafts.mockResolvedValue([]); + mockScopesApi.listWorkflows.mockResolvedValue([]); + + renderWithQueryClient(); + expect(await screen.findByText("No workflows yet")).toBeInTheDocument(); + }); + + it("renders total source failure and supports retry", async () => { + mockStudioApi.listWorkflowDrafts.mockRejectedValue(new Error("offline")); + mockScopesApi.listWorkflows.mockRejectedValue(new Error("offline")); + renderWithQueryClient(); + + expect(await screen.findByText("Workflows unavailable")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry workflows" })).toBeEnabled(); + }); +}); + +describe("Workflow Activity vNext settings", () => { + beforeEach(() => { + mockPathname = "/scopes/scope-alpha/workflow-activity-vnext/settings"; + jest.clearAllMocks(); + mockStudioApi.getUserLlmSettings.mockResolvedValue({ + savedSelection: null, + savedRouteLabel: "System default", + selectionStatus: "system_default", + catalogDiagnostic: "unspecified", + remediation: "none", + routeOptions: [], + modelGroupsByRoute: [], + catalogStatus: "empty", + capabilities: { + canEditRoute: true, + canEditModel: true, + canSave: true, + canRetryCatalog: true, + }, + }); + mockStudioApi.getAuthSession.mockResolvedValue({ + enabled: true, + authenticated: true, + providerDisplayName: "NyxID", + profile: { + subject: "user-subject-alpha", + name: "Ada Operator", + email: "ada@example.test", + emailVerified: true, + picture: null, + roles: ["operator"], + groups: ["platform"], + }, + session: { + authenticated: true, + scopeId: "scope-alpha", + scopeSource: "nyxid-session", + expiresAtUtc: "2026-08-05T10:00:00Z", + }, + }); + mockStudioApi.getUserConfigRuntime.mockResolvedValue({ + runtimeMode: "remote", + activeRuntimeBaseUrl: "https://runtime.example.test", + localRuntimeBaseUrl: "http://localhost:5100", + remoteRuntimeBaseUrl: "https://runtime.example.test", + runtimeDefaults: { + localRuntimeBaseUrl: "http://localhost:5100", + remoteRuntimeBaseUrl: "https://runtime.example.test", + localMode: "local", + remoteMode: "remote", + }, + }); + }); + + afterEach(() => cleanupTestQueryClients()); + + it("renders real AI, account, and runtime facts in one scoped settings surface", async () => { + renderWithQueryClient(); + + expect((await screen.findAllByText("System default")).length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole("tab", { name: "Account" })); + expect(await screen.findByText("Ada Operator")).toBeInTheDocument(); + expect(screen.getByText("user-subject-alpha")).toBeInTheDocument(); + expect(screen.getByText("NyxID")).toBeInTheDocument(); + expect(screen.getByText("operator")).toBeInTheDocument(); + expect(screen.getByText("platform")).toBeInTheDocument(); + expect(screen.getByText("nyxid-session")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "Advanced" })); + expect(await screen.findAllByText("https://runtime.example.test")).toHaveLength(2); + expect(screen.getByText("remote")).toBeInTheDocument(); + }); + + it("discards dirty AI defaults back to the last authoritative selection", async () => { + mockStudioApi.getUserLlmSettings.mockResolvedValue({ + savedSelection: { + routeKind: "gateway", + routeValue: "/api/v1/llm/gateway/v1", + modelSelection: { kind: "provider_default" }, + }, + savedRouteLabel: "Gateway", + selectionStatus: "ready", + catalogDiagnostic: "unspecified", + remediation: "none", + catalogStatus: "ready", + capabilities: { + canEditRoute: true, + canEditModel: true, + canSave: true, + canRetryCatalog: true, + }, + routeOptions: [ + { + routeValue: "/api/v1/llm/gateway/v1", + label: "Gateway", + source: "gateway_provider", + status: "ready", + allowed: true, + ready: true, + userServiceId: null, + serviceSlug: null, + modelCatalog: { + certainty: "not_verifiable", + modelIds: [], + defaultModelId: null, + diagnostic: "not_published", + }, + description: null, + }, + { + routeValue: "/api/v1/proxy/s/service-alpha", + label: "Service alpha", + source: "user_service", + status: "ready", + allowed: true, + ready: true, + userServiceId: "us-alpha", + serviceSlug: "service-alpha", + modelCatalog: { + certainty: "enumerated", + modelIds: ["model-alpha"], + defaultModelId: "model-alpha", + diagnostic: "unspecified", + }, + description: null, + }, + ], + modelGroupsByRoute: [], + }); + + renderWithQueryClient(); + + const routeSelect = await screen.findByRole("combobox", { name: "AI route" }); + fireEvent.mouseDown(routeSelect); + fireEvent.click(await screen.findByText("Service alpha")); + expect(screen.getByRole("button", { name: "Save AI defaults" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Discard changes" })); + expect(screen.getByRole("button", { name: "Save AI defaults" })).toBeDisabled(); + expect(mockStudioApi.saveUserLlmSettings).not.toHaveBeenCalled(); + }); +}); + +describe("Workflow Activity vNext editor", () => { + beforeEach(() => { + mockPathname = + "/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-committed-source"; + jest.clearAllMocks(); + mockStudioApi.getWorkspaceSettings.mockResolvedValue({ + runtimeBaseUrl: "", + directories: [ + { directoryId: "directory-alpha", label: "Workflows", path: "/workflows", isBuiltIn: true }, + ], + }); + mockStudioApi.getWorkflow.mockResolvedValue({ + workflowId: "wf-committed-source", + name: "Committed source", + fileName: "committed-source.yaml", + filePath: "", + directoryId: "", + directoryLabel: "", + yaml: "name: committed_source\nroles: []\nsteps:\n - id: step-root\n type: llm_call\n parameters:\n prompt_prefix: Original prompt\n", + updatedAtUtc: "2026-08-04T10:00:00Z", + document: { + name: "committed_source", + roles: [], + steps: [ + { + id: "step-root", + type: "llm_call", + parameters: { prompt_prefix: "Original prompt" }, + }, + ], + }, + draftExists: false, + findings: [], + }); + mockStudioApi.parseYaml.mockResolvedValue({ + document: { name: "committed_source", roles: [], steps: [] }, + findings: [], + }); + mockStudioApi.serializeYaml.mockResolvedValue({ + yaml: "name: committed_source\nroles: []\nsteps: []\n", + document: { name: "committed_source", roles: [], steps: [] }, + findings: [], + }); + mockStudioApi.saveWorkflow.mockResolvedValue({ + kind: "materialized", + workflow: { + workflowId: "wf-draft-new", + name: "Committed source", + fileName: "committed-source.yaml", + filePath: "/workflows/committed-source.yaml", + directoryId: "directory-alpha", + directoryLabel: "Workflows", + yaml: "name: committed_source\nroles: []\nsteps: []\n", + updatedAtUtc: "2026-08-04T10:01:00Z", + document: { name: "committed_source", roles: [], steps: [] }, + draftExists: true, + findings: [], + }, + }); + }); + + afterEach(() => cleanupTestQueryClients()); + + it("creates on first save for committed-only source and adopts the API-returned draft id", async () => { + renderWithQueryClient(); + + expect(await screen.findByDisplayValue("Committed source")).toBeInTheDocument(); + fireEvent.click(screen.getByText("YAML")); + fireEvent.change(screen.getByLabelText("Workflow YAML"), { + target: { value: "name: committed_source\nroles: []\nsteps: []\n\n" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save workflow" })); + + await waitFor(() => expect(mockStudioApi.saveWorkflow).toHaveBeenCalledTimes(1)); + expect(mockStudioApi.saveWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + draftExists: false, + workflowId: "wf-committed-source", + directoryId: "directory-alpha", + }), + ); + expect(history.replace).toHaveBeenCalledWith( + "/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-draft-new", + ); + }); + + it("edits a selected canvas node through the shared document state", async () => { + mockStudioApi.serializeYaml.mockImplementation(async ({ document }) => ({ + yaml: "name: committed_source\nroles: []\nsteps:\n - id: step-root\n type: llm_call\n parameters:\n prompt_prefix: Updated prompt\n", + document, + findings: [], + })); + + renderWithQueryClient(); + + fireEvent.click(await screen.findByRole("button", { name: "Select step:step-root" })); + expect(screen.getByText("Configuring step-root")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Apply node configuration" })); + + await waitFor(() => + expect(mockStudioApi.serializeYaml).toHaveBeenCalledWith({ + document: expect.objectContaining({ + steps: [ + expect.objectContaining({ + id: "step-root", + parameters: { prompt_prefix: "Updated prompt" }, + }), + ], + }), + }), + ); + }); + + it("offers Save, Discard, and Stay before vNext navigation with unsaved changes", async () => { + renderWithQueryClient(); + + expect(await screen.findByDisplayValue("Committed source")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Workflow name"), { + target: { value: "Unsaved title" }, + }); + fireEvent.click(screen.getAllByRole("link", { name: "Activity" })[0]); + + expect(history.push).not.toHaveBeenCalledWith( + "/scopes/scope-alpha/workflow-activity-vnext/activity", + ); + expect(screen.getByRole("dialog", { name: "Unsaved workflow changes" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save and leave" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Discard and leave" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Stay" })); + expect(screen.queryByRole("dialog", { name: "Unsaved workflow changes" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("link", { name: "Activity" })[0]); + fireEvent.click(screen.getByRole("button", { name: "Discard and leave" })); + expect(history.push).toHaveBeenCalledWith( + "/scopes/scope-alpha/workflow-activity-vnext/activity", + ); + }); +}); + +describe("Workflow Activity vNext creation", () => { + beforeEach(() => { + mockPathname = "/scopes/scope-alpha/workflow-activity-vnext/workflows/new"; + jest.clearAllMocks(); + mockStudioApi.getWorkspaceSettings.mockResolvedValue({ + runtimeBaseUrl: "", + directories: [ + { directoryId: "directory-alpha", label: "Workflows", path: "/workflows", isBuiltIn: true }, + ], + }); + }); + + afterEach(() => cleanupTestQueryClients()); + + it("creates a blank draft with a server directory and navigates only after materialization", async () => { + mockStudioApi.createWorkflowDraft.mockResolvedValue({ + kind: "materialized", + workflow: { + workflowId: "wf-created-alpha", + name: "Incident review", + fileName: "incident-review.yaml", + filePath: "/workflows/incident-review.yaml", + directoryId: "directory-alpha", + directoryLabel: "Workflows", + yaml: "name: incident_review\nroles: []\nsteps: []\n", + updatedAtUtc: "2026-08-04T10:00:00Z", + document: { name: "incident_review", roles: [], steps: [] }, + draftExists: true, + findings: [], + }, + }); + + renderWithQueryClient(); + const blankButton = await screen.findByRole("button", { name: "Start blank" }); + await waitFor(() => expect(blankButton).toBeEnabled()); + fireEvent.click(blankButton); + fireEvent.change(screen.getByLabelText("Workflow name"), { + target: { value: "Incident review" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create blank draft" })); + + await waitFor(() => expect(mockStudioApi.createWorkflowDraft).toHaveBeenCalledTimes(1)); + expect(mockStudioApi.createWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ directoryId: "directory-alpha", scopeId: "scope-alpha" }), + ); + expect(history.push).toHaveBeenCalledWith( + "/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-created-alpha", + ); + }); + + it("validates imported YAML before creating and preserves invalid input", async () => { + mockStudioApi.parseYaml.mockResolvedValue({ + document: null, + findings: [{ level: "error", code: "YAML_INVALID", message: "Invalid YAML" }], + }); + + renderWithQueryClient(); + const importButton = await screen.findByRole("button", { name: "Import YAML" }); + await waitFor(() => expect(importButton).toBeEnabled()); + fireEvent.click(importButton); + fireEvent.change(screen.getByLabelText("Workflow YAML"), { + target: { value: "name: [broken" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Validate and create" })); + + expect(await screen.findByText("Invalid YAML")).toBeInTheDocument(); + expect(mockStudioApi.createWorkflowDraft).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Workflow YAML")).toHaveValue("name: [broken"); + }); +}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx new file mode 100644 index 0000000000..b3e1231e92 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { t } from "@/shared/i18n/messages"; +import WorkflowsPage from "./workflows/WorkflowsPage"; +import NewWorkflowPage from "./workflows/NewWorkflowPage"; +import WorkflowEditorPage from "./workflows/WorkflowEditorPage"; +import ActivityPage from "./activity/ActivityPage"; +import RunDetailPage from "./activity/RunDetailPage"; +import SettingsPage from "./settings/SettingsPage"; +import { useConsoleLocation } from "./hooks/useConsoleLocation"; +import WorkflowActivityVNextShell from "./WorkflowActivityVNextShell"; + +const WorkflowActivityVNextPage: React.FC = () => { + const location = useConsoleLocation(); + const pathname = location.pathname; + const scopeMatch = /^\/scopes\/([^/]+)\/workflow-activity-vnext(?:\/|$)/.exec(pathname); + const scopeId = scopeMatch ? decodeURIComponent(scopeMatch[1]) : ""; + + if (pathname.endsWith("/workflows/new")) { + return ; + } + + if (pathname.endsWith("/workflows")) { + return ; + } + + const workflowMatch = /\/workflows\/([^/]+)$/.exec(pathname); + if (workflowMatch) { + return ; + } + + const runMatch = /\/activity\/([^/]+)$/.exec(pathname); + if (runMatch) { + return ; + } + + if (pathname.endsWith("/activity")) { + return ; + } + + if (pathname.endsWith("/settings")) { + return ; + } + + return ( + +
+

{t("workflowActivityVNext.unavailable.body", "The requested capability is not currently available from the frontend route.")}

+
+
+ ); +}; + +export default WorkflowActivityVNextPage; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/navigation.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/navigation.ts new file mode 100644 index 0000000000..659abe60cf --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/navigation.ts @@ -0,0 +1,34 @@ +export type WorkflowActivitySection = "workflows" | "activity" | "settings"; + +function encode(value: string): string { + return encodeURIComponent(value.trim()); +} + +export function buildWorkflowActivityBaseHref(scopeId: string): string { + return `/scopes/${encode(scopeId)}/workflow-activity-vnext`; +} + +export function buildWorkflowActivitySectionHref( + scopeId: string, + section: WorkflowActivitySection, +): string { + return `${buildWorkflowActivityBaseHref(scopeId)}/${section}`; +} + +export function buildWorkflowActivityNewHref(scopeId: string): string { + return `${buildWorkflowActivitySectionHref(scopeId, "workflows")}/new`; +} + +export function buildWorkflowActivityEditorHref( + scopeId: string, + workflowId: string, +): string { + return `${buildWorkflowActivitySectionHref(scopeId, "workflows")}/${encode(workflowId)}`; +} + +export function buildWorkflowActivityRunHref( + scopeId: string, + runId: string, +): string { + return `${buildWorkflowActivitySectionHref(scopeId, "activity")}/${encode(runId)}`; +} diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/settings/SettingsPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/settings/SettingsPage.tsx new file mode 100644 index 0000000000..ebc1fb4139 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/settings/SettingsPage.tsx @@ -0,0 +1,138 @@ +import { ReloadOutlined, SaveOutlined } from "@ant-design/icons"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Alert, Button, Descriptions, Select, Space, Tabs } from "antd"; +import React from "react"; +import { + buildUserLlmSelectionOptions, + cloneUserLlmSelection, + decodeUserLlmSelectionValue, + encodeUserLlmSelectionValue, + resolveSavedUserLlmSelection, + userLlmSelectionsEqual, + type UserLlmSelectionDraft, +} from "@/pages/settings/userLlmSelection"; +import { observeUserLlmSave } from "@/pages/settings/userLlmSaveObservation"; +import { t } from "@/shared/i18n/messages"; +import { history } from "@/shared/navigation/history"; +import { studioApi } from "@/shared/studio/api"; +import WorkflowActivityVNextShell from "../WorkflowActivityVNextShell"; +import { useConsoleLocation } from "../hooks/useConsoleLocation"; + +type SettingsSection = "ai" | "account" | "advanced"; +type SavePhase = "idle" | "saving" | "accepted" | "observed" | "delayed" | "failed"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function readSection(search: string): SettingsSection { + const value = new URLSearchParams(search).get("section"); + return value === "account" || value === "advanced" ? value : "ai"; +} + +const SettingsPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { + const location = useConsoleLocation(); + const queryClient = useQueryClient(); + const [section, setSection] = React.useState(() => readSection(location.search)); + const llm = useQuery({ queryKey: ["workflow-activity-vnext", "settings", "llm"], queryFn: ({ signal }) => studioApi.getUserLlmSettings(signal), retry: false }); + const auth = useQuery({ queryKey: ["workflow-activity-vnext", "settings", "auth"], queryFn: () => studioApi.getAuthSession(), retry: false }); + const runtime = useQuery({ queryKey: ["workflow-activity-vnext", "settings", "runtime"], queryFn: () => studioApi.getUserConfigRuntime(), retry: false }); + const [draft, setDraft] = React.useState(undefined); + const [baseline, setBaseline] = React.useState(undefined); + const [savePhase, setSavePhase] = React.useState("idle"); + const [saveMessage, setSaveMessage] = React.useState(""); + const saveTokenRef = React.useRef(0); + const loadedRef = React.useRef(false); + + React.useEffect(() => { + if (!llm.data || loadedRef.current) return; + const saved = resolveSavedUserLlmSelection(llm.data); + setDraft(saved); + setBaseline(saved); + loadedRef.current = true; + }, [llm.data]); + React.useEffect(() => () => { + saveTokenRef.current += 1; + }, []); + + const options = React.useMemo(() => buildUserLlmSelectionOptions(llm.data?.routeOptions ?? []), [llm.data?.routeOptions]); + const selectedOption = draft ? options.find((item) => item.value === encodeUserLlmSelectionValue(draft)) : undefined; + const modelIds = selectedOption?.modelCatalog.modelIds ?? []; + const modelValue = draft?.modelSelection.kind === "explicit_model" ? draft.modelSelection.modelId : "provider-default"; + const dirty = !userLlmSelectionsEqual(draft, baseline); + + const selectRoute = (value: string) => { + if (!value) { + setDraft(undefined); + return; + } + const next = decodeUserLlmSelectionValue(value, options); + if (next) setDraft(cloneUserLlmSelection(next)); + }; + const selectModel = (value: string) => { + if (!draft) return; + setDraft({ ...draft, modelSelection: value === "provider-default" ? { kind: "provider_default" } : { kind: "explicit_model", modelId: value } } as UserLlmSelectionDraft); + }; + + const save = async () => { + if (!dirty || savePhase === "saving" || savePhase === "accepted") return; + const submitted = draft ? cloneUserLlmSelection(draft) : undefined; + const token = ++saveTokenRef.current; + setSavePhase("saving"); + setSaveMessage(""); + try { + const receipt = await studioApi.saveUserLlmSettings( + !submitted + ? { action: "reset" } + : submitted.routeKind === "gateway" + ? { action: "select_gateway", gateway: { model: submitted.modelSelection } } + : { action: "select_user_service", userService: { userServiceId: submitted.nyxIdUserServiceId, model: submitted.modelSelection } }, + ); + if (!receipt.accepted) throw new Error(t("workflowActivityVNext.settings.notAccepted", "The settings update was not accepted.")); + setSavePhase("accepted"); + setSaveMessage(receipt.commandId); + const observation = await observeUserLlmSave({ + saveToken: token, + isCurrent: (candidate) => candidate === saveTokenRef.current, + read: (signal) => studioApi.getUserLlmSettings(signal), + isObserved: (sample) => userLlmSelectionsEqual(resolveSavedUserLlmSelection(sample), submitted), + onResponse: (sample) => queryClient.setQueryData(["workflow-activity-vnext", "settings", "llm"], sample), + }); + if (token !== saveTokenRef.current) return; + if (observation.phase === "observed") { + setBaseline(submitted ? cloneUserLlmSelection(submitted) : undefined); + setSavePhase("observed"); + } else if (observation.phase === "accepted_unobserved") { + setSavePhase("delayed"); + } + } catch (error) { + if (token === saveTokenRef.current) { + setSaveMessage(errorMessage(error)); + setSavePhase("failed"); + } + } + }; + + const discard = () => { + saveTokenRef.current += 1; + setDraft(baseline ? cloneUserLlmSelection(baseline) : undefined); + setSavePhase("idle"); + setSaveMessage(""); + }; + + const changeSection = (key: string) => { + const next = key as SettingsSection; + setSection(next); + history.replace(`${location.pathname}${next === "ai" ? "" : `?section=${next}`}`); + }; + + const aiPanel = llm.isPending ?

{t("workflowActivityVNext.settings.llmLoading", "Loading AI defaults")}

: llm.isError ?

{t("workflowActivityVNext.settings.llmUnavailable", "AI defaults unavailable")}

{errorMessage(llm.error)}

:
{t("workflowActivityVNext.settings.route", "AI route")} ({ label: modelId, value: modelId }))]} style={{ display: "block", marginTop: 6, width: "100%" }} value={modelValue} />
: null}{savePhase !== "idle" ? : null}
; + + const accountPanel = auth.isPending ?

{t("workflowActivityVNext.settings.accountLoading", "Loading account session")}

: auth.isError ?

{t("workflowActivityVNext.settings.accountUnavailable", "Account session unavailable")}

{errorMessage(auth.error)}

: {auth.data?.profile?.subject || t("workflowActivityVNext.common.unavailable", "Unavailable")} }, { key: "provider", label: t("workflowActivityVNext.settings.provider", "Provider"), children: auth.data?.providerDisplayName || auth.data?.session?.providerDisplayName || t("workflowActivityVNext.common.unavailable", "Unavailable") }, { key: "roles", label: t("workflowActivityVNext.settings.roles", "Roles"), children: auth.data?.profile?.roles.length ? auth.data.profile.roles.join(", ") : t("workflowActivityVNext.common.unavailable", "Unavailable") }, { key: "groups", label: t("workflowActivityVNext.settings.groups", "Groups"), children: auth.data?.profile?.groups.length ? auth.data.profile.groups.join(", ") : t("workflowActivityVNext.common.unavailable", "Unavailable") }, { key: "scope", label: t("workflowActivityVNext.scope", "Scope"), children: {auth.data?.session?.scopeId || auth.data?.scopeId || t("workflowActivityVNext.common.unavailable", "Unavailable")} }, { key: "scopeSource", label: t("workflowActivityVNext.settings.scopeSource", "Scope source"), children: auth.data?.session?.scopeSource || auth.data?.scopeSource || t("workflowActivityVNext.common.unavailable", "Unavailable") }, { key: "expiry", label: t("workflowActivityVNext.settings.expires", "Expires"), children: auth.data?.session?.expiresAtUtc || t("workflowActivityVNext.common.unavailable", "Unavailable") }]} />; + + const runtimePanel = runtime.isPending ?

{t("workflowActivityVNext.settings.runtimeLoading", "Loading effective runtime")}

: runtime.isError ?

{t("workflowActivityVNext.settings.runtimeUnavailable", "Effective runtime unavailable")}

{errorMessage(runtime.error)}

: {runtime.data?.activeRuntimeBaseUrl} }, { key: "local", label: t("workflowActivityVNext.settings.localRuntime", "Local runtime URL"), children: {runtime.data?.localRuntimeBaseUrl} }, { key: "remote", label: t("workflowActivityVNext.settings.remoteRuntime", "Remote runtime URL"), children: {runtime.data?.remoteRuntimeBaseUrl} }]} />; + + return ; +}; + +export default SettingsPage; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts new file mode 100644 index 0000000000..02f343ab06 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts @@ -0,0 +1,97 @@ +export const workflowActivityVNextCss = ` +.wa-vnext { + --wa-ink: #17202a; + --wa-muted: #667085; + --wa-faint: #98a2b3; + --wa-line: #d0d5dd; + --wa-surface: #ffffff; + --wa-subtle: #f8fafc; + --wa-sidebar: #101828; + --wa-blue: #175cd3; + --wa-blue-bg: #eff8ff; + --wa-green: #067647; + --wa-green-bg: #ecfdf3; + --wa-amber: #b54708; + --wa-amber-bg: #fffaeb; + --wa-red: #b42318; + --wa-red-bg: #fef3f2; + color: var(--wa-ink); + display: grid; + font-family: "Avenir Next", "Segoe UI", sans-serif; + grid-template-columns: 216px minmax(0, 1fr); + min-height: calc(100vh - 56px); +} +.wa-vnext * { box-sizing: border-box; letter-spacing: 0; } +.wa-vnext__rail { + background: var(--wa-sidebar); + border-right: 1px solid #344054; + color: #fff; + display: flex; + flex-direction: column; + min-height: calc(100vh - 56px); + padding: 22px 12px 18px; +} +.wa-vnext__brand { padding: 0 12px 28px; } +.wa-vnext__brand strong { display: block; font-size: 18px; } +.wa-vnext__brand span { color: #98a2b3; display: block; font: 11px ui-monospace, monospace; margin-top: 4px; } +.wa-vnext__nav { display: grid; gap: 6px; } +.wa-vnext__nav-button { + align-items: center; background: transparent; border: 1px solid transparent; + border-radius: 6px; color: #d0d5dd; display: flex; gap: 11px; min-height: 42px; + padding: 0 14px; text-decoration: none; +} +.wa-vnext__nav-button:hover { background: #1d2939; color: #fff; } +.wa-vnext__nav-button[aria-current="page"] { background: #344054; border-color: #475467; color: #fff; } +.wa-vnext__rail-foot { border-top: 1px solid #344054; margin-top: auto; min-width: 0; padding: 14px 12px 0; } +.wa-vnext__rail-foot span { color: #98a2b3; font-size: 12px; } +.wa-vnext__rail-foot strong { display: block; font: 12px ui-monospace, monospace; margin-top: 5px; overflow-wrap: anywhere; } +.wa-vnext__main { background: var(--wa-surface); min-width: 0; } +.wa-vnext__mobile-nav { display: none; } +.wa-vnext__header { align-items: center; border-bottom: 1px solid var(--wa-line); display: flex; gap: 18px; justify-content: space-between; min-height: 84px; padding: 18px 30px; } +.wa-vnext__header h1 { font-size: 22px; line-height: 1.25; margin: 0; } +.wa-vnext__header p { color: var(--wa-muted); font-size: 13px; margin: 5px 0 0; } +.wa-vnext__header-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; } +.wa-vnext__content { padding: 24px 30px 56px; } +.wa-vnext__toolbar { align-items: end; display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between; margin-bottom: 18px; } +.wa-vnext__table-wrap { border: 1px solid var(--wa-line); overflow-x: auto; } +.wa-vnext__table { border-collapse: collapse; min-width: 720px; table-layout: fixed; width: 100%; } +.wa-vnext__table th { background: var(--wa-subtle); border-bottom: 1px solid var(--wa-line); color: var(--wa-muted); font: 11px ui-monospace, monospace; height: 42px; padding: 0 12px; text-align: left; text-transform: uppercase; } +.wa-vnext__table td { border-bottom: 1px solid var(--wa-line); height: 72px; padding: 10px 12px; vertical-align: middle; } +.wa-vnext__table tr:last-child td { border-bottom: 0; } +.wa-vnext__title { display: block; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wa-vnext__sub { color: var(--wa-muted); display: block; font-size: 12px; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.wa-vnext__status { align-items: center; border: 1px solid currentColor; border-radius: 5px; display: inline-flex; font-size: 12px; font-weight: 650; gap: 6px; min-height: 26px; padding: 0 9px; white-space: nowrap; } +.wa-vnext__status::before { background: currentColor; border-radius: 50%; content: ""; height: 7px; width: 7px; } +.wa-vnext__status--draft { background: #f4f3ff; color: #6941c6; } +.wa-vnext__status--committed, .wa-vnext__status--succeeded { background: var(--wa-green-bg); color: var(--wa-green); } +.wa-vnext__status--running, .wa-vnext__status--accepted { background: var(--wa-blue-bg); color: var(--wa-blue); } +.wa-vnext__status--failed { background: var(--wa-red-bg); color: var(--wa-red); } +.wa-vnext__status--pending, .wa-vnext__status--unknown { background: var(--wa-amber-bg); color: var(--wa-amber); } +.wa-vnext__state { background: var(--wa-subtle); border: 1px dashed var(--wa-line); display: grid; min-height: 260px; padding: 32px; place-items: center; text-align: center; } +.wa-vnext__state h2 { font-size: 18px; margin: 0 0 8px; } +.wa-vnext__state p { color: var(--wa-muted); margin: 0 0 18px; } +.wa-vnext__notice { border: 1px solid #fdb022; background: var(--wa-amber-bg); color: #7a2e0e; margin-bottom: 14px; padding: 11px 13px; } +.wa-vnext__notice--error { background: var(--wa-red-bg); border-color: #fda29b; color: var(--wa-red); } +.wa-vnext__panel { border: 1px solid var(--wa-line); padding: 20px; } +.wa-vnext__form { display: grid; gap: 18px; max-width: 760px; } +.wa-vnext__form-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.wa-vnext__settings-actions--dirty { background: var(--wa-subtle); border: 1px solid var(--wa-line); bottom: 12px; padding: 10px; position: sticky; z-index: 8; } +.wa-vnext__mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } +.wa-vnext__split { display: grid; gap: 20px; grid-template-columns: minmax(0, 1fr) minmax(280px, 34%); } +.wa-vnext button:focus-visible, .wa-vnext a:focus-visible, .wa-vnext input:focus-visible, .wa-vnext textarea:focus-visible, .wa-vnext select:focus-visible { outline: 3px solid rgba(23, 92, 211, .25); outline-offset: 2px; } +@media (max-width: 900px) { + .wa-vnext { grid-template-columns: 1fr; } + .wa-vnext__rail { display: none; } + .wa-vnext__mobile-nav { background: var(--wa-sidebar); display: flex; gap: 4px; overflow-x: auto; padding: 8px 10px; } + .wa-vnext__mobile-nav .wa-vnext__nav-button { flex: 1 0 auto; justify-content: center; min-height: 38px; } + .wa-vnext__split { grid-template-columns: 1fr; } +} +@media (max-width: 600px) { + .wa-vnext__header { align-items: flex-start; flex-direction: column; min-height: 0; padding: 16px; } + .wa-vnext__header-actions { justify-content: flex-start; width: 100%; } + .wa-vnext__content { padding: 18px 16px 44px; } + .wa-vnext__toolbar { align-items: stretch; flex-direction: column; } + .wa-vnext__toolbar .ant-input-affix-wrapper { width: 100% !important; } +} +@media (prefers-reduced-motion: reduce) { .wa-vnext *, .wa-vnext *::before, .wa-vnext *::after { scroll-behavior: auto !important; transition: none !important; } } +`; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.tsx new file mode 100644 index 0000000000..fcf7cb13c0 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.tsx @@ -0,0 +1,206 @@ +import { + ArrowLeftOutlined, + FileAddOutlined, + FileTextOutlined, + ImportOutlined, + RobotOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, Button, Input, Select, Space, Typography } from "antd"; +import React from "react"; +import { t } from "@/shared/i18n/messages"; +import { history } from "@/shared/navigation/history"; +import { studioApi } from "@/shared/studio/api"; +import type { StudioValidationFinding, StudioWorkflowSaveResult } from "@/shared/studio/models"; +import WorkflowActivityVNextShell from "../WorkflowActivityVNextShell"; +import { buildWorkflowActivityEditorHref, buildWorkflowActivitySectionHref } from "../navigation"; +import { useDraftMaterialization } from "../hooks/useDraftMaterialization"; +import { + BUNDLED_WORKFLOW_TEMPLATES, + createBlankWorkflowYaml, + hasBlockingFindings, + slugifyWorkflowFileName, + type WorkflowCreationMode, +} from "./workflowCreation"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const NewWorkflowPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { + const modeItems: readonly { + readonly key: WorkflowCreationMode; + readonly icon: React.ReactNode; + readonly label: string; + readonly description: string; + }[] = [ + { key: "describe", icon: , label: t("workflowActivityVNext.new.mode.describe", "Describe"), description: t("workflowActivityVNext.new.mode.describe.description", "Generate a draft from a goal, review it, then persist it.") }, + { key: "blank", icon: , label: t("workflowActivityVNext.new.mode.blank", "Start blank"), description: t("workflowActivityVNext.new.mode.blank.description", "Persist an empty workflow document and add its first node in the editor.") }, + { key: "import", icon: , label: t("workflowActivityVNext.new.mode.import", "Import YAML"), description: t("workflowActivityVNext.new.mode.import.description", "Validate YAML with the editor API before creating a draft.") }, + { key: "template", icon: , label: t("workflowActivityVNext.new.mode.template", "Use template"), description: t("workflowActivityVNext.new.mode.template.description", "Copy versioned bundled product content into an independent persisted draft.") }, + ]; + const [mode, setMode] = React.useState(null); + const [name, setName] = React.useState(""); + const [prompt, setPrompt] = React.useState(""); + const [yaml, setYaml] = React.useState(""); + const [generatedYaml, setGeneratedYaml] = React.useState(""); + const [generatedReady, setGeneratedReady] = React.useState(false); + const [templateId, setTemplateId] = React.useState(BUNDLED_WORKFLOW_TEMPLATES[0]?.id ?? ""); + const [directoryId, setDirectoryId] = React.useState(""); + const [findings, setFindings] = React.useState([]); + const [submitting, setSubmitting] = React.useState(false); + const [failure, setFailure] = React.useState(""); + const materialization = useDraftMaterialization(scopeId); + const workspace = useQuery({ + queryKey: ["workflow-activity-vnext", "workspace", scopeId], + queryFn: () => studioApi.getWorkspaceSettings(scopeId), + retry: false, + }); + + React.useEffect(() => { + if (!directoryId && workspace.data?.directories[0]?.directoryId) { + setDirectoryId(workspace.data.directories[0].directoryId); + } + }, [directoryId, workspace.data]); + + const navigateToWorkflow = React.useCallback( + (workflowId: string) => history.push(buildWorkflowActivityEditorHref(scopeId, workflowId)), + [scopeId], + ); + + const finishSave = React.useCallback( + async (result: StudioWorkflowSaveResult) => { + if (result.kind === "materialized") { + navigateToWorkflow(result.workflow.workflowId); + return; + } + const readable = await materialization.observe(result.receipt); + if (readable) navigateToWorkflow(readable.workflowId); + }, + [materialization.observe, navigateToWorkflow], + ); + + const persist = async (nextYaml: string, suggestedName?: string) => { + const workflowName = (name || suggestedName || "").trim(); + if (!workflowName || !directoryId || submitting) return; + setSubmitting(true); + setFailure(""); + try { + await finishSave( + await studioApi.createWorkflowDraft({ + directoryId, + fileName: slugifyWorkflowFileName(workflowName), + scopeId, + workflowName, + yaml: nextYaml, + }), + ); + } catch (error) { + setFailure(errorMessage(error)); + } finally { + setSubmitting(false); + } + }; + + const validateAndPersist = async (nextYaml: string) => { + if (!nextYaml.trim() || submitting) return; + setSubmitting(true); + setFailure(""); + setFindings([]); + try { + const parsed = await studioApi.parseYaml({ yaml: nextYaml }); + setFindings(parsed.findings); + if (hasBlockingFindings(parsed.document, parsed.findings)) return; + const parsedName = String(parsed.document?.name ?? "").trim(); + setSubmitting(false); + await persist(nextYaml, parsedName); + } catch (error) { + setFailure(errorMessage(error)); + } finally { + setSubmitting(false); + } + }; + + const generate = async () => { + if (!prompt.trim() || submitting) return; + setSubmitting(true); + setFailure(""); + setFindings([]); + setGeneratedYaml(""); + setGeneratedReady(false); + try { + const generated = await studioApi.authorWorkflow( + { prompt }, + { onText: setGeneratedYaml }, + ); + const parsed = await studioApi.parseYaml({ yaml: generated }); + setGeneratedYaml(generated); + setFindings(parsed.findings); + if (hasBlockingFindings(parsed.document, parsed.findings)) return; + setGeneratedReady(true); + if (!name.trim()) setName(String(parsed.document?.name ?? "").trim()); + } catch (error) { + setFailure(errorMessage(error)); + } finally { + setSubmitting(false); + } + }; + + const retryObservation = async () => { + const readable = await materialization.retry(); + if (readable) navigateToWorkflow(readable.workflowId); + }; + + const selectedTemplate = BUNDLED_WORKFLOW_TEMPLATES.find((item) => item.id === templateId); + const templateName = t("workflowActivityVNext.new.templateName.incidentTriage", "Incident triage"); + const templateDescription = t("workflowActivityVNext.new.templateDescription.incidentTriage", "Classify an incident, prepare a response, and request human approval."); + const disabledByWorkspace = workspace.isPending || workspace.isError || workspace.data?.directories.length === 0; + + return ( + } onClick={() => history.push(buildWorkflowActivitySectionHref(scopeId, "workflows"))}>{t("workflowActivityVNext.new.back", "Back to workflows")}} + scopeId={scopeId} + title={t("workflowActivityVNext.new.title", "New workflow")} + > + {workspace.isPending ? : null} + {workspace.isError ? void workspace.refetch()}>{t("workflowActivityVNext.common.retry", "Retry")}} message={t("workflowActivityVNext.new.workspaceUnavailable", "Workspace directories unavailable")} showIcon type="error" /> : null} + {workspace.data?.directories.length === 0 ? : null} + + {!mode ? ( +
+ {modeItems.map((item) => ( + + ))} +
+ ) : ( +
+
+ {modeItems.find((item) => item.key === mode)?.label ?? mode} +
{t("workflowActivityVNext.new.directory", "Workflow directory")} setName(event.target.value)} style={{ marginTop: 6 }} value={name} />
+ + {mode === "describe" ? <>
{t("workflowActivityVNext.new.goal", "Automation goal")} setPrompt(event.target.value)} rows={5} style={{ marginTop: 6 }} value={prompt} />
{generatedYaml ?
{t("workflowActivityVNext.new.generatedYaml", "Generated YAML")} { setGeneratedYaml(event.target.value); setGeneratedReady(false); }} rows={12} style={{ fontFamily: "ui-monospace, monospace", marginTop: 6 }} value={generatedYaml} />
: null}
{generatedYaml && generatedReady ? : null}
: null} + + {mode === "blank" ? : null} + + {mode === "import" ? <>
{t("workflowActivityVNext.new.yaml", "Workflow YAML")} setYaml(event.target.value)} rows={16} style={{ fontFamily: "ui-monospace, monospace", marginTop: 6 }} value={yaml} />
: null} + + {mode === "template" ? <>
{t("workflowActivityVNext.new.template", "Template")} editor.updateTitle(event.target.value)} style={{ maxWidth: 420 }} value={editor.workflowTitle} /> + {editor.dirty ? t("workflowActivityVNext.editor.unsaved", "Unsaved") : t("workflowActivityVNext.editor.saved", "Saved")} setMode(value as "canvas" | "yaml")} options={[{ label: t("workflowActivityVNext.editor.canvas", "Canvas"), value: "canvas", icon: }, { label: t("workflowActivityVNext.editor.yaml", "YAML"), value: "yaml", icon: }]} value={mode} /> +
+ {editor.saveError ? : null} + {editor.materialization.phase !== "idle" && editor.materialization.receipt ? void editor.materialization.retry()}>{t("workflowActivityVNext.new.retryObservation", "Retry draft observation")} : undefined} description={t("workflowActivityVNext.new.receipt", "Workflow {workflowId} · command {commandId}", { workflowId: editor.materialization.receipt.workflowId, commandId: editor.materialization.receipt.commandId })} message={editor.materialization.phase === "accepted" ? t("workflowActivityVNext.common.accepted", "Accepted") : editor.materialization.phase === "observing" ? t("workflowActivityVNext.new.observing", "Draft accepted; observing readability") : editor.materialization.phase === "delayed" ? t("workflowActivityVNext.new.projectionDelayed", "Projection is delayed") : editor.materialization.phase === "failed" ? t("workflowActivityVNext.new.observationFailed", "Draft observation failed") : t("workflowActivityVNext.editor.saved", "Saved")} showIcon type={editor.materialization.phase === "failed" ? "error" : editor.materialization.phase === "delayed" ? "warning" : editor.materialization.phase === "readable" ? "success" : "info"} /> : null} + {editor.findings.length > 0 ?
{editor.findings.map((finding) => )}
: null} +
+ {mode === "canvas" ? <>
setNodeLibraryOpen(true)} onCanvasSelect={editor.selectCanvas} onNodeSelect={editor.selectNode} selectedNodeId={editor.selectedNodeId} />
setNodeLibraryOpen(false)} onInsertNode={(stepType) => { void editor.addNode(stepType); setNodeLibraryOpen(false); }} open={nodeLibraryOpen} /> void editor.updateSelectedStepConfiguration(parametersText)} onConfigurationErrorChange={editor.setSelectedStepConfigurationError} stepDraft={editor.selectedStepDraft} /> : editor.updateYaml(event.target.value)} style={{ border: 0, borderRadius: 0, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", height: "100%", resize: "none" }} value={editor.yaml} />} +
+ {runPanelOpen ?
{t("workflowActivityVNext.editor.runPanel", "Draft run")} editor.setRunInput(event.target.value)} rows={4} value={editor.runInput} />{editor.runPhase !== "idle" ? : null}
: null} + setPendingNavigation("")}>{t("workflowActivityVNext.editor.stay", "Stay")}, + , + , + ]} + onCancel={() => setPendingNavigation("")} + open={Boolean(pendingNavigation)} + title={t("workflowActivityVNext.editor.unsavedTitle", "Unsaved workflow changes")} + > +

{t("workflowActivityVNext.editor.unsavedDescription", "Save this authoritative draft, discard the local edits, or stay in the editor.")}

+
+ + ); +}; + +export default WorkflowEditorPage; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowsPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowsPage.tsx new file mode 100644 index 0000000000..262d5cfdc8 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowsPage.tsx @@ -0,0 +1,206 @@ +import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Alert, Button, Input, Space } from "antd"; +import React from "react"; +import { scopesApi } from "@/shared/api/scopesApi"; +import { t } from "@/shared/i18n/messages"; +import { history } from "@/shared/navigation/history"; +import { studioApi } from "@/shared/studio/api"; +import type { StudioWorkflowDraftSummary } from "@/shared/studio/models"; +import type { ScopeWorkflowSummary } from "@/shared/models/scopes"; +import WorkflowActivityVNextShell from "../WorkflowActivityVNextShell"; +import { + buildWorkflowActivityEditorHref, + buildWorkflowActivityNewHref, + buildWorkflowActivitySectionHref, +} from "../navigation"; + +type WorkflowRow = { + readonly description: string; + readonly hasCommittedSource: boolean; + readonly name: string; + readonly source: "draft" | "committed"; + readonly stepCount?: number; + readonly updatedAtUtc: string | null; + readonly workflowId: string; +}; + +function toDraftRow( + item: StudioWorkflowDraftSummary, + committed?: WorkflowRow, +): WorkflowRow { + return { + description: item.description, + hasCommittedSource: Boolean(committed), + name: item.name, + source: "draft", + stepCount: item.stepCount, + updatedAtUtc: item.updatedAtUtc, + workflowId: item.workflowId, + }; +} + +function toCommittedRow(item: ScopeWorkflowSummary): WorkflowRow { + return { + description: item.workflowName, + hasCommittedSource: true, + name: item.displayName || item.workflowName, + source: "committed", + updatedAtUtc: item.updatedAt, + workflowId: item.workflowId, + }; +} + +function formatDate(value: string | null): string { + if (!value) return t("workflowActivityVNext.common.unavailable", "Unavailable"); + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); +} + +const WorkflowsPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { + const [query, setQuery] = React.useState(""); + const [activityWorkflowId, setActivityWorkflowId] = React.useState(""); + const [activityError, setActivityError] = React.useState(""); + const drafts = useQuery({ + queryKey: ["workflow-activity-vnext", "drafts", scopeId], + queryFn: () => studioApi.listWorkflowDrafts(scopeId), + retry: false, + }); + const committed = useQuery({ + queryKey: ["workflow-activity-vnext", "committed", scopeId], + queryFn: () => scopesApi.listWorkflows(scopeId), + retry: false, + }); + + const loading = drafts.isPending || committed.isPending; + const rows = React.useMemo(() => { + const merged = new Map(); + for (const item of committed.data ?? []) merged.set(item.workflowId, toCommittedRow(item)); + for (const item of drafts.data ?? []) merged.set(item.workflowId, toDraftRow(item, merged.get(item.workflowId))); + const normalized = query.trim().toLowerCase(); + return [...merged.values()] + .filter((item) => + !normalized || [item.name, item.description, item.workflowId].some((value) => value.toLowerCase().includes(normalized)), + ) + .sort((left, right) => Date.parse(right.updatedAtUtc ?? "") - Date.parse(left.updatedAtUtc ?? "")); + }, [committed.data, drafts.data, query]); + const totalFailure = drafts.isError && committed.isError; + + const retry = () => { + void drafts.refetch(); + void committed.refetch(); + }; + + const openActivity = async (row: WorkflowRow) => { + const activityHref = buildWorkflowActivitySectionHref(scopeId, "activity"); + setActivityError(""); + if (!row.hasCommittedSource) { + history.push(`${activityHref}?workflowFilter=unavailable`); + return; + } + + setActivityWorkflowId(row.workflowId); + try { + const detail = await scopesApi.getWorkflowDetail(scopeId, row.workflowId); + const definitionActorId = detail.source?.definitionActorId.trim() ?? ""; + history.push( + definitionActorId + ? `${activityHref}?definition=${encodeURIComponent(definitionActorId)}` + : `${activityHref}?workflowFilter=unavailable`, + ); + } catch (error) { + setActivityError(error instanceof Error ? error.message : String(error)); + } finally { + setActivityWorkflowId(""); + } + }; + + return ( + } onClick={() => history.push(buildWorkflowActivityNewHref(scopeId))} type="primary"> + {t("workflowActivityVNext.workflows.new", "New workflow")} + + } + scopeId={scopeId} + title={t("workflowActivityVNext.workflows.title", "Workflows")} + > +
+ setQuery(event.target.value)} + placeholder={t("workflowActivityVNext.workflows.search", "Search workflows")} + prefix={} + role="searchbox" + style={{ width: 360 }} + value={query} + /> + +
+ + {drafts.isError && !committed.isError ? ( + + ) : null} + {committed.isError && !drafts.isError ? ( + + ) : null} + {activityError ? ( + + ) : null} + + {loading ? ( +
+

{t("workflowActivityVNext.workflows.loading", "Loading workflows")}

+
+ ) : totalFailure ? ( +
+
+

{t("workflowActivityVNext.workflows.unavailable", "Workflows unavailable")}

+

{t("workflowActivityVNext.workflows.unavailableDescription", "Neither authoritative catalogue source could be loaded.")}

+ +
+
+ ) : rows.length === 0 ? ( +
+
+

{query ? t("workflowActivityVNext.workflows.noMatch", "No matching workflows") : t("workflowActivityVNext.workflows.empty", "No workflows yet")}

+

{query ? t("workflowActivityVNext.workflows.noMatchDescription", "Try a different search.") : t("workflowActivityVNext.workflows.emptyDescription", "Create a persisted draft to begin.")}

+ {!query ? : null} +
+
+ ) : ( +
+ + + + {rows.map((row) => ( + + + + + + + ))} + +
{t("workflowActivityVNext.workflows.columnWorkflow", "Workflow")}{t("workflowActivityVNext.workflows.columnState", "State")}{t("workflowActivityVNext.workflows.columnUpdated", "Updated")}{t("workflowActivityVNext.workflows.columnActions", "Actions")}
{row.name}{row.description || row.workflowId}{row.source === "draft" ? t("workflowActivityVNext.workflows.draft", "Draft") : t("workflowActivityVNext.workflows.committed", "Committed")}{formatDate(row.updatedAtUtc)}
+
+ )} +
+ ); +}; + +export default WorkflowsPage; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowCreation.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowCreation.ts new file mode 100644 index 0000000000..e2468bd3fd --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowCreation.ts @@ -0,0 +1,60 @@ +import type { StudioWorkflowDocument } from "@/shared/studio/models"; + +export type WorkflowCreationMode = "describe" | "blank" | "import" | "template"; + +export type BundledWorkflowTemplate = { + readonly id: string; + readonly version: string; + readonly yaml: string; +}; + +export const BUNDLED_WORKFLOW_TEMPLATES: readonly BundledWorkflowTemplate[] = [ + { + id: "incident-triage", + version: "2026.08.1", + yaml: `name: incident_triage +description: Classify an incident and prepare a reviewed response. +roles: + - id: responder + name: Incident responder + systemPrompt: Review incident reports carefully. +steps: + - id: classify + type: llm_call + targetRole: responder + parameters: + prompt_prefix: Classify severity and summarize impact. + next: approve + - id: approve + type: human_approval + parameters: + prompt: Approve the proposed response? + on_reject: fail +`, + }, +] as const; + +export function slugifyWorkflowFileName(name: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${slug || "workflow"}.yaml`; +} + +export function createBlankWorkflowYaml(name: string): string { + const documentName = name.trim().replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "workflow"; + return `name: ${documentName}\ndescription: \nroles: []\nsteps: []\n`; +} + +export function hasBlockingFindings( + document: StudioWorkflowDocument | null | undefined, + findings: readonly { readonly level?: string | number; readonly message: string }[], +): boolean { + if (!document) return true; + return findings.some((finding) => { + const level = String(finding.level ?? "").toLowerCase(); + return level === "error" || level === "fatal" || level === "2" || level === "3"; + }); +} diff --git a/apps/aevatar-console-web/src/routesConfig.test.ts b/apps/aevatar-console-web/src/routesConfig.test.ts index 793507bd6d..0791edd760 100644 --- a/apps/aevatar-console-web/src/routesConfig.test.ts +++ b/apps/aevatar-console-web/src/routesConfig.test.ts @@ -153,4 +153,40 @@ describe("console routes", () => { ); expect(findRoute(routes, "/settings").name).toBe("Settings"); }); + + it("isolates Workflow Activity vNext under its hidden scoped namespace", () => { + const routes = loadRoutes(); + const namespace = "/scopes/:scopeId/workflow-activity-vnext"; + const expectedRoutes = [ + namespace, + `${namespace}/workflows`, + `${namespace}/workflows/new`, + `${namespace}/workflows/:workflowId`, + `${namespace}/activity`, + `${namespace}/activity/:runId`, + `${namespace}/settings`, + ]; + + for (const path of expectedRoutes) { + expect(findRoute(routes, path).hideInMenu).toBe(true); + } + + expect(findRoute(routes, namespace).redirect).toBe( + `${namespace}/workflows`, + ); + expect(findRoute(routes, `${namespace}/workflows`).component).toBe( + "./workflow-activity-vnext", + ); + expect( + findRouteIndex(routes, `${namespace}/workflows/new`), + ).toBeLessThan( + findRouteIndex(routes, `${namespace}/workflows/:workflowId`), + ); + + expect(findRoute(routes, "/workflows").redirect).toBe( + "/runtime/workflows", + ); + expect(findRoute(routes, "/runs").redirect).toBe("/runtime/runs"); + expect(findRoute(routes, "/").redirect).toBe("/scopes"); + }); }); diff --git a/apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts new file mode 100644 index 0000000000..e53d0a72ec --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.test.ts @@ -0,0 +1,219 @@ +import { + type WorkflowActivityApiError, + workflowActivityApi, +} from "./workflowActivityApi"; + +describe("workflowActivityApi", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + function jsonResponse(payload: unknown, status = 200): Partial { + return { + json: async () => payload, + ok: status >= 200 && status < 300, + status, + statusText: status === 403 ? "Forbidden" : "OK", + text: async () => JSON.stringify(payload), + }; + } + + it("encodes supported server filters and preserves an unknown status", async () => { + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse([ + { + runId: "run-alpha", + workflowName: "Support triage", + status: "future_terminal_state", + success: null, + startedAtUtc: null, + updatedAtUtc: "2026-08-04T10:00:00Z", + stateVersion: 17, + scopeId: "scope-alpha", + runOrigin: "draft", + }, + ]), + ); + global.fetch = fetchMock as typeof global.fetch; + + await expect( + workflowActivityApi.listRuns("scope-alpha", { + status: "failed", + origins: ["draft", "member-invoke"], + definitionActorIds: ["definition-alpha"], + scheduleIds: ["schedule-alpha"], + fromUtc: "2026-08-01T00:00:00Z", + toUtc: "2026-08-05T00:00:00Z", + take: 25, + }), + ).resolves.toEqual([ + expect.objectContaining({ + runId: "run-alpha", + stateVersion: 17, + status: "future_terminal_state", + }), + ]); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/workflow/observatory/runs?scope=scope-alpha&status=failed&origin=draft%2Cmember-invoke&definition=definition-alpha&schedule=schedule-alpha&from=2026-08-01T00%3A00%3A00Z&to=2026-08-05T00%3A00%3A00Z&take=25", + ); + }); + + it("loads immutable detail and graph through independent resources", async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ + summary: { + runId: "run-alpha", + workflowName: "Support triage", + status: "failed", + success: false, + startedAtUtc: "2026-08-04T09:58:00Z", + updatedAtUtc: "2026-08-04T10:00:00Z", + stateVersion: 18, + scopeId: "scope-alpha", + runOrigin: "draft", + }, + input: "ticket-42", + finalOutput: "", + finalError: "Connector unavailable", + diagnostics: [], + steps: [ + { + stepId: "step-failed", + stepType: "connector_call", + targetRole: "", + requestedAtUtc: "2026-08-04T09:59:00Z", + completedAtUtc: "2026-08-04T10:00:00Z", + success: false, + durationMs: 1000, + outputPreview: "", + error: "Connector unavailable", + requestParameters: {}, + nextStepId: "", + branchKey: "", + suspensionType: "", + suspensionPrompt: "", + suspensionContent: "", + suspensionTimeoutSeconds: null, + toolApproval: null, + usage: { promptTokens: 1, completionTokens: 0, totalTokens: 1, cost: 0 }, + }, + ], + timeline: [], + statistics: { + totalSteps: 1, + requestedSteps: 1, + completedSteps: 1, + roleReplyCount: 0, + stepTypeCounts: { connector_call: 1 }, + }, + usageTotals: { promptTokens: 1, completionTokens: 0, totalTokens: 1, cost: 0 }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + rootNodeId: "node-root", + nodes: [ + { nodeId: "node-step", nodeType: "WorkflowStep", stepId: "step-failed" }, + ], + edges: [], + }), + ); + global.fetch = fetchMock as typeof global.fetch; + + const detail = await workflowActivityApi.getRun("scope-alpha", "run-alpha"); + const graph = await workflowActivityApi.getRunGraph( + "scope-alpha", + "run-alpha", + ); + + expect(detail.steps[0]?.stepId).toBe("step-failed"); + expect(graph.nodes[0]?.stepId).toBe("step-failed"); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "/api/workflow/observatory/runs/run-alpha?scope=scope-alpha", + "/api/workflow/observatory/runs/run-alpha/graph?scope=scope-alpha", + ]); + }); + + it("submits a fork without treating its actor identity as a run identity", async () => { + const fetchMock = jest.fn().mockResolvedValue( + jsonResponse( + { + accepted: true, + sourceRunId: "run-alpha", + newRunActorId: "actor-new", + workflowName: "Support triage", + acceptedCommandId: "command-fork", + correlationId: "correlation-fork", + statusUrl: "/api/workflow-actors/actor-new/current-state", + }, + 202, + ), + ); + global.fetch = fetchMock as typeof global.fetch; + + await expect( + workflowActivityApi.forkRun({ + sourceRunId: "run-alpha", + startAtStepId: "step-failed", + input: "ticket-42", + }), + ).resolves.toEqual( + expect.objectContaining({ + newRunActorId: "actor-new", + sourceRunId: "run-alpha", + }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/workflow/runs/fork", + expect.objectContaining({ + body: JSON.stringify({ + sourceRunId: "run-alpha", + startAtStepId: "step-failed", + input: "ticket-42", + }), + method: "POST", + }), + ); + }); + + it("preserves authorization status and rejects malformed authoritative identity", async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce( + jsonResponse({ code: "SCOPE_ACCESS_DENIED", message: "Forbidden" }, 403), + ) + .mockResolvedValueOnce( + jsonResponse([ + { + runId: "", + workflowName: "Support triage", + status: "running", + success: null, + startedAtUtc: null, + updatedAtUtc: "2026-08-04T10:00:00Z", + stateVersion: 1, + scopeId: "scope-alpha", + runOrigin: "draft", + }, + ]), + ); + global.fetch = fetchMock as typeof global.fetch; + + await expect( + workflowActivityApi.listRuns("scope-other"), + ).rejects.toMatchObject>({ + code: "SCOPE_ACCESS_DENIED", + status: 403, + }); + await expect( + workflowActivityApi.listRuns("scope-alpha"), + ).rejects.toThrow("runId must not be blank"); + }); +}); diff --git a/apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts new file mode 100644 index 0000000000..8df75bb1bb --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/workflowActivityApi.ts @@ -0,0 +1,486 @@ +import { + expectArray, + expectRecord, + expectString, + readBoolean, + readNumber, + readString, + readStringRecord, +} from "@/shared/api/http/decoders"; +import { readResponseErrorDetails } from "@/shared/api/http/error"; +import { withQuery } from "@/shared/api/http/client"; +import { authFetch } from "@/shared/auth/fetch"; +import type { + WorkflowActivityDiagnostic, + WorkflowActivityGraphEdge, + WorkflowActivityGraphNode, + WorkflowActivityRunDetail, + WorkflowActivityRunFilter, + WorkflowActivityRunGraph, + WorkflowActivityRunStatistics, + WorkflowActivityRunSummary, + WorkflowActivityStep, + WorkflowActivityTimelineEvent, + WorkflowActivityToolApproval, + WorkflowActivityToolCall, + WorkflowActivityUsageTotals, + WorkflowRunForkAcceptedReceipt, + WorkflowRunForkRequest, +} from "@/shared/models/workflowActivity"; + +const JSON_HEADERS = { + Accept: "application/json", + "Content-Type": "application/json", +}; + +export class WorkflowActivityApiError extends Error { + readonly code?: string; + readonly status: number; + + constructor(message: string, status: number, code?: string) { + super(message); + this.name = "WorkflowActivityApiError"; + this.code = code; + this.status = status; + } +} + +function requireNonBlank(value: string, label: string): string { + if (!value.trim()) { + throw new Error(`${label} must not be blank.`); + } + return value; +} + +function readNonBlank( + record: Record, + key: string, + label: string, +): string { + return requireNonBlank(readString(record, key, label), label); +} + +function readNullableStringValue(value: unknown, label: string): string | null { + return value === null || value === undefined ? null : expectString(value, label); +} + +function readNullableBooleanValue(value: unknown, label: string): boolean | null { + if (value === null || value === undefined) return null; + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean.`); + return value; +} + +function readNullableNumberValue(value: unknown, label: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || Number.isNaN(value)) { + throw new Error(`${label} must be a number.`); + } + return value; +} + +function decodeUsage(value: unknown, label: string): WorkflowActivityUsageTotals { + const record = expectRecord(value, label); + return { + promptTokens: readNumber(record, "promptTokens", `${label}.promptTokens`), + completionTokens: readNumber( + record, + "completionTokens", + `${label}.completionTokens`, + ), + totalTokens: readNumber(record, "totalTokens", `${label}.totalTokens`), + cost: readNumber(record, "cost", `${label}.cost`), + }; +} + +function decodeSummary( + value: unknown, + label = "WorkflowActivityRunSummary", +): WorkflowActivityRunSummary { + const record = expectRecord(value, label); + return { + runId: readNonBlank(record, "runId", `${label}.runId`), + workflowName: readString(record, "workflowName", `${label}.workflowName`), + status: readString(record, "status", `${label}.status`), + success: readNullableBooleanValue(record.success, `${label}.success`), + startedAtUtc: readNullableStringValue( + record.startedAtUtc, + `${label}.startedAtUtc`, + ), + updatedAtUtc: readNonBlank(record, "updatedAtUtc", `${label}.updatedAtUtc`), + stateVersion: readNumber(record, "stateVersion", `${label}.stateVersion`), + scopeId: readNonBlank(record, "scopeId", `${label}.scopeId`), + runOrigin: readString(record, "runOrigin", `${label}.runOrigin`), + }; +} + +function decodeDiagnostic( + value: unknown, + label = "WorkflowActivityDiagnostic", +): WorkflowActivityDiagnostic { + const record = expectRecord(value, label); + return { + timestampUtc: readNullableStringValue(record.timestampUtc, `${label}.timestampUtc`), + severity: readString(record, "severity", `${label}.severity`), + code: readString(record, "code", `${label}.code`), + source: readString(record, "source", `${label}.source`), + message: readString(record, "message", `${label}.message`), + hint: readString(record, "hint", `${label}.hint`), + stepId: readString(record, "stepId", `${label}.stepId`), + stepType: readString(record, "stepType", `${label}.stepType`), + targetRole: readString(record, "targetRole", `${label}.targetRole`), + }; +} + +function decodeToolApproval(value: unknown, label: string): WorkflowActivityToolApproval { + const record = expectRecord(value, label); + return { + executionId: readString(record, "executionId", `${label}.executionId`), + toolName: readString(record, "toolName", `${label}.toolName`), + toolCallId: readString(record, "toolCallId", `${label}.toolCallId`), + approvalRequestId: readString( + record, + "approvalRequestId", + `${label}.approvalRequestId`, + ), + }; +} + +function decodeStep( + value: unknown, + label = "WorkflowActivityStep", +): WorkflowActivityStep { + const record = expectRecord(value, label); + return { + stepId: readNonBlank(record, "stepId", `${label}.stepId`), + stepType: readString(record, "stepType", `${label}.stepType`), + targetRole: readString(record, "targetRole", `${label}.targetRole`), + requestedAtUtc: readNullableStringValue( + record.requestedAtUtc, + `${label}.requestedAtUtc`, + ), + completedAtUtc: readNullableStringValue( + record.completedAtUtc, + `${label}.completedAtUtc`, + ), + success: readNullableBooleanValue(record.success, `${label}.success`), + durationMs: readNullableNumberValue(record.durationMs, `${label}.durationMs`), + outputPreview: readString(record, "outputPreview", `${label}.outputPreview`), + error: readString(record, "error", `${label}.error`), + requestParameters: readStringRecord( + record, + "requestParameters", + `${label}.requestParameters`, + ), + nextStepId: readString(record, "nextStepId", `${label}.nextStepId`), + branchKey: readString(record, "branchKey", `${label}.branchKey`), + suspensionType: readString( + record, + "suspensionType", + `${label}.suspensionType`, + ), + suspensionPrompt: readString( + record, + "suspensionPrompt", + `${label}.suspensionPrompt`, + ), + suspensionContent: readString( + record, + "suspensionContent", + `${label}.suspensionContent`, + ), + suspensionTimeoutSeconds: readNullableNumberValue( + record.suspensionTimeoutSeconds, + `${label}.suspensionTimeoutSeconds`, + ), + toolApproval: + record.toolApproval === null || record.toolApproval === undefined + ? null + : decodeToolApproval(record.toolApproval, `${label}.toolApproval`), + usage: decodeUsage(record.usage, `${label}.usage`), + }; +} + +function decodeToolCall(value: unknown, label: string): WorkflowActivityToolCall { + const record = expectRecord(value, label); + return { + toolName: readString(record, "toolName", `${label}.toolName`), + callId: readString(record, "callId", `${label}.callId`), + argumentsJson: readString(record, "argumentsJson", `${label}.argumentsJson`), + resultJson: readString(record, "resultJson", `${label}.resultJson`), + success: readBoolean(record, "success", `${label}.success`), + error: readString(record, "error", `${label}.error`), + }; +} + +function decodeTimelineEvent( + value: unknown, + label = "WorkflowActivityTimelineEvent", +): WorkflowActivityTimelineEvent { + const record = expectRecord(value, label); + return { + kind: readString(record, "kind", `${label}.kind`), + timestampUtc: readNonBlank(record, "timestampUtc", `${label}.timestampUtc`), + stage: readString(record, "stage", `${label}.stage`), + message: readString(record, "message", `${label}.message`), + agentId: readString(record, "agentId", `${label}.agentId`), + stepId: readString(record, "stepId", `${label}.stepId`), + stepType: readString(record, "stepType", `${label}.stepType`), + toolCall: + record.toolCall === null || record.toolCall === undefined + ? null + : decodeToolCall(record.toolCall, `${label}.toolCall`), + content: readString(record, "content", `${label}.content`), + data: readStringRecord(record, "data", `${label}.data`), + }; +} + +function decodeStatistics( + value: unknown, + label: string, +): WorkflowActivityRunStatistics { + const record = expectRecord(value, label); + const counts = expectRecord(record.stepTypeCounts, `${label}.stepTypeCounts`); + return { + totalSteps: readNumber(record, "totalSteps", `${label}.totalSteps`), + requestedSteps: readNumber( + record, + "requestedSteps", + `${label}.requestedSteps`, + ), + completedSteps: readNumber( + record, + "completedSteps", + `${label}.completedSteps`, + ), + roleReplyCount: readNumber( + record, + "roleReplyCount", + `${label}.roleReplyCount`, + ), + stepTypeCounts: Object.fromEntries( + Object.entries(counts).map(([key, entry]) => { + if (typeof entry !== "number" || Number.isNaN(entry)) { + throw new Error(`${label}.stepTypeCounts.${key} must be a number.`); + } + return [key, entry]; + }), + ), + }; +} + +function decodeDetail(value: unknown): WorkflowActivityRunDetail { + const record = expectRecord(value, "WorkflowActivityRunDetail"); + return { + summary: decodeSummary(record.summary, "WorkflowActivityRunDetail.summary"), + input: readString(record, "input", "WorkflowActivityRunDetail.input"), + finalOutput: readString( + record, + "finalOutput", + "WorkflowActivityRunDetail.finalOutput", + ), + finalError: readString( + record, + "finalError", + "WorkflowActivityRunDetail.finalError", + ), + diagnostics: expectArray( + record.diagnostics, + "WorkflowActivityRunDetail.diagnostics", + decodeDiagnostic, + ), + steps: expectArray(record.steps, "WorkflowActivityRunDetail.steps", decodeStep), + timeline: expectArray( + record.timeline, + "WorkflowActivityRunDetail.timeline", + decodeTimelineEvent, + ), + statistics: decodeStatistics( + record.statistics, + "WorkflowActivityRunDetail.statistics", + ), + usageTotals: decodeUsage( + record.usageTotals, + "WorkflowActivityRunDetail.usageTotals", + ), + }; +} + +function decodeGraphNode( + value: unknown, + label = "WorkflowActivityGraphNode", +): WorkflowActivityGraphNode { + const record = expectRecord(value, label); + return { + nodeId: readNonBlank(record, "nodeId", `${label}.nodeId`), + nodeType: readString(record, "nodeType", `${label}.nodeType`), + stepId: readString(record, "stepId", `${label}.stepId`), + }; +} + +function decodeGraphEdge( + value: unknown, + label = "WorkflowActivityGraphEdge", +): WorkflowActivityGraphEdge { + const record = expectRecord(value, label); + return { + edgeId: readNonBlank(record, "edgeId", `${label}.edgeId`), + fromNodeId: readNonBlank(record, "fromNodeId", `${label}.fromNodeId`), + toNodeId: readNonBlank(record, "toNodeId", `${label}.toNodeId`), + edgeType: readString(record, "edgeType", `${label}.edgeType`), + branchKey: readString(record, "branchKey", `${label}.branchKey`), + }; +} + +function decodeGraph(value: unknown): WorkflowActivityRunGraph { + const record = expectRecord(value, "WorkflowActivityRunGraph"); + return { + rootNodeId: readString( + record, + "rootNodeId", + "WorkflowActivityRunGraph.rootNodeId", + ), + nodes: expectArray( + record.nodes, + "WorkflowActivityRunGraph.nodes", + decodeGraphNode, + ), + edges: expectArray( + record.edges, + "WorkflowActivityRunGraph.edges", + decodeGraphEdge, + ), + }; +} + +function decodeForkReceipt(value: unknown): WorkflowRunForkAcceptedReceipt { + const record = expectRecord(value, "WorkflowRunForkAcceptedReceipt"); + if (readBoolean(record, "accepted", "WorkflowRunForkAcceptedReceipt.accepted") !== true) { + throw new Error("WorkflowRunForkAcceptedReceipt.accepted must be true."); + } + return { + accepted: true, + sourceRunId: readNonBlank( + record, + "sourceRunId", + "WorkflowRunForkAcceptedReceipt.sourceRunId", + ), + newRunActorId: readNonBlank( + record, + "newRunActorId", + "WorkflowRunForkAcceptedReceipt.newRunActorId", + ), + workflowName: readString( + record, + "workflowName", + "WorkflowRunForkAcceptedReceipt.workflowName", + ), + acceptedCommandId: readNonBlank( + record, + "acceptedCommandId", + "WorkflowRunForkAcceptedReceipt.acceptedCommandId", + ), + correlationId: readNonBlank( + record, + "correlationId", + "WorkflowRunForkAcceptedReceipt.correlationId", + ), + statusUrl: readNonBlank( + record, + "statusUrl", + "WorkflowRunForkAcceptedReceipt.statusUrl", + ), + }; +} + +async function requestActivityJson( + input: string, + decode: (value: unknown) => T, + init?: RequestInit, +): Promise { + const response = await authFetch(input, init); + if (!response.ok) { + const details = await readResponseErrorDetails(response); + throw new WorkflowActivityApiError( + details.message, + details.status, + details.code, + ); + } + return decode(await response.json()); +} + +function joinFilter(values?: readonly string[]): string | undefined { + const normalized = values?.map((value) => value.trim()).filter(Boolean) ?? []; + return normalized.length > 0 ? normalized.join(",") : undefined; +} + +function compactForkRequest(input: WorkflowRunForkRequest): Record { + return Object.fromEntries( + Object.entries({ + sourceRunId: input.sourceRunId.trim(), + startAtStepId: input.startAtStepId.trim(), + inlineYaml: input.inlineYaml, + inlineSubYamls: input.inlineSubYamls, + variableOverrides: input.variableOverrides, + input: input.input, + commandId: input.commandId?.trim() || undefined, + correlationId: input.correlationId?.trim() || undefined, + }).filter(([, value]) => value !== undefined), + ); +} + +export const workflowActivityApi = { + listRuns( + scopeId: string, + filter: WorkflowActivityRunFilter = {}, + ): Promise { + const url = withQuery("/api/workflow/observatory/runs", { + scope: scopeId.trim(), + status: filter.status?.trim(), + origin: joinFilter(filter.origins), + definition: joinFilter(filter.definitionActorIds), + schedule: joinFilter(filter.scheduleIds), + from: filter.fromUtc?.trim(), + to: filter.toUtc?.trim(), + take: filter.take, + }); + return requestActivityJson(url, (value) => + expectArray(value, "WorkflowActivityRunSummary[]", decodeSummary), + ); + }, + + getRun(scopeId: string, runId: string): Promise { + return requestActivityJson( + withQuery( + `/api/workflow/observatory/runs/${encodeURIComponent(runId.trim())}`, + { scope: scopeId.trim() }, + ), + decodeDetail, + ); + }, + + getRunGraph( + scopeId: string, + runId: string, + ): Promise { + return requestActivityJson( + withQuery( + `/api/workflow/observatory/runs/${encodeURIComponent(runId.trim())}/graph`, + { scope: scopeId.trim() }, + ), + decodeGraph, + ); + }, + + forkRun(input: WorkflowRunForkRequest): Promise { + return requestActivityJson( + "/api/workflow/runs/fork", + decodeForkReceipt, + { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(compactForkRequest(input)), + }, + ); + }, +}; diff --git a/apps/aevatar-console-web/src/shared/models/workflowActivity.ts b/apps/aevatar-console-web/src/shared/models/workflowActivity.ts new file mode 100644 index 0000000000..fe9fdc73c0 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/models/workflowActivity.ts @@ -0,0 +1,151 @@ +export interface WorkflowActivityRunFilter { + readonly status?: string; + readonly origins?: readonly string[]; + readonly definitionActorIds?: readonly string[]; + readonly scheduleIds?: readonly string[]; + readonly fromUtc?: string; + readonly toUtc?: string; + readonly take?: number; +} + +export interface WorkflowActivityRunSummary { + readonly runId: string; + readonly workflowName: string; + readonly status: string; + readonly success: boolean | null; + readonly startedAtUtc: string | null; + readonly updatedAtUtc: string; + readonly stateVersion: number; + readonly scopeId: string; + readonly runOrigin: string; +} + +export interface WorkflowActivityUsageTotals { + readonly promptTokens: number; + readonly completionTokens: number; + readonly totalTokens: number; + readonly cost: number; +} + +export interface WorkflowActivityDiagnostic { + readonly timestampUtc: string | null; + readonly severity: string; + readonly code: string; + readonly source: string; + readonly message: string; + readonly hint: string; + readonly stepId: string; + readonly stepType: string; + readonly targetRole: string; +} + +export interface WorkflowActivityToolApproval { + readonly executionId: string; + readonly toolName: string; + readonly toolCallId: string; + readonly approvalRequestId: string; +} + +export interface WorkflowActivityStep { + readonly stepId: string; + readonly stepType: string; + readonly targetRole: string; + readonly requestedAtUtc: string | null; + readonly completedAtUtc: string | null; + readonly success: boolean | null; + readonly durationMs: number | null; + readonly outputPreview: string; + readonly error: string; + readonly requestParameters: Readonly>; + readonly nextStepId: string; + readonly branchKey: string; + readonly suspensionType: string; + readonly suspensionPrompt: string; + readonly suspensionContent: string; + readonly suspensionTimeoutSeconds: number | null; + readonly toolApproval: WorkflowActivityToolApproval | null; + readonly usage: WorkflowActivityUsageTotals; +} + +export interface WorkflowActivityToolCall { + readonly toolName: string; + readonly callId: string; + readonly argumentsJson: string; + readonly resultJson: string; + readonly success: boolean; + readonly error: string; +} + +export interface WorkflowActivityTimelineEvent { + readonly kind: string; + readonly timestampUtc: string; + readonly stage: string; + readonly message: string; + readonly agentId: string; + readonly stepId: string; + readonly stepType: string; + readonly toolCall: WorkflowActivityToolCall | null; + readonly content: string; + readonly data: Readonly>; +} + +export interface WorkflowActivityRunStatistics { + readonly totalSteps: number; + readonly requestedSteps: number; + readonly completedSteps: number; + readonly roleReplyCount: number; + readonly stepTypeCounts: Readonly>; +} + +export interface WorkflowActivityRunDetail { + readonly summary: WorkflowActivityRunSummary; + readonly input: string; + readonly finalOutput: string; + readonly finalError: string; + readonly diagnostics: readonly WorkflowActivityDiagnostic[]; + readonly steps: readonly WorkflowActivityStep[]; + readonly timeline: readonly WorkflowActivityTimelineEvent[]; + readonly statistics: WorkflowActivityRunStatistics; + readonly usageTotals: WorkflowActivityUsageTotals; +} + +export interface WorkflowActivityGraphNode { + readonly nodeId: string; + readonly nodeType: string; + readonly stepId: string; +} + +export interface WorkflowActivityGraphEdge { + readonly edgeId: string; + readonly fromNodeId: string; + readonly toNodeId: string; + readonly edgeType: string; + readonly branchKey: string; +} + +export interface WorkflowActivityRunGraph { + readonly rootNodeId: string; + readonly nodes: readonly WorkflowActivityGraphNode[]; + readonly edges: readonly WorkflowActivityGraphEdge[]; +} + +export interface WorkflowRunForkRequest { + readonly sourceRunId: string; + readonly startAtStepId: string; + readonly inlineYaml?: string; + readonly inlineSubYamls?: Readonly>; + readonly variableOverrides?: Readonly>; + readonly input?: string; + readonly commandId?: string; + readonly correlationId?: string; +} + +export interface WorkflowRunForkAcceptedReceipt { + readonly accepted: true; + readonly sourceRunId: string; + readonly newRunActorId: string; + readonly workflowName: string; + readonly acceptedCommandId: string; + readonly correlationId: string; + readonly statusUrl: string; +} From d879b88ba61781b3acc8df70d1cab852f2b3e17d Mon Sep 17 00:00:00 2001 From: Abigail Deng Date: Wed, 5 Aug 2026 09:59:23 +0800 Subject: [PATCH 02/18] Render Workflow Activity vNext as standalone workbench --- .../src/app.layout.test.ts | 31 +++++++++++++++++++ apps/aevatar-console-web/src/app.tsx | 23 ++++++++++++-- .../activity/ActivityPage.tsx | 6 ++-- .../pages/workflow-activity-vnext/styles.ts | 29 ++++++++++++++--- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/apps/aevatar-console-web/src/app.layout.test.ts b/apps/aevatar-console-web/src/app.layout.test.ts index 8f1f79d89b..ba38dd6437 100644 --- a/apps/aevatar-console-web/src/app.layout.test.ts +++ b/apps/aevatar-console-web/src/app.layout.test.ts @@ -85,6 +85,37 @@ describe("layout menu collapse behavior", () => { }); }); + it("renders Workflow Activity vNext without the global console chrome", () => { + window.history.replaceState( + {}, + "", + "/scopes/scope-a/workflow-activity-vnext/workflows/wf-a", + ); + + const runtimeLayout = layout({ + initialState: { + auth: {} as never, + settings: defaultSettings, + }, + }); + const menuRender = runtimeLayout.menuRender as + | ((props: unknown, defaultDom: unknown) => React.ReactNode) + | undefined; + const actionsRender = runtimeLayout.actionsRender as + | ((props: unknown, dom: unknown) => React.ReactNode[]) + | undefined; + + expect(runtimeLayout.headerRender).toBe(false); + expect(menuRender?.({}, React.createElement("nav"))).toBe(false); + expect(actionsRender?.({}, {})).toEqual([]); + expect(runtimeLayout.contentStyle).toMatchObject({ + background: "#ffffff", + height: "100vh", + overflow: "hidden", + padding: 0, + }); + }); + it("updates the controlled global menu collapse state after SPA route changes", () => { window.history.replaceState({}, "", "/scopes/scope-a/teams"); const teamsLayout = layout({ diff --git a/apps/aevatar-console-web/src/app.tsx b/apps/aevatar-console-web/src/app.tsx index c8263aa0ec..62f2f506c4 100644 --- a/apps/aevatar-console-web/src/app.tsx +++ b/apps/aevatar-console-web/src/app.tsx @@ -48,6 +48,8 @@ import { AevatarPageLoading } from "@/shared/ui/AevatarLoading"; const PUBLIC_ROUTES = new Set(["/login", "/auth/callback"]); const DEFAULT_PROTECTED_ROUTE = CONSOLE_HOME_ROUTE; const FULLSCREEN_DISPLAY_ROUTES = new Set(["/runtime/mission-wall"]); +const WORKFLOW_ACTIVITY_VNEXT_ROUTE = + /^\/scopes\/[^/]+\/workflow-activity-vnext(?:\/|$)/; const STUDIO_HOST_ROUTES = new Set([ "/studio", "/scopes/:scopeId/teams/:teamId/members/new/workflow", @@ -55,7 +57,14 @@ const STUDIO_HOST_ROUTES = new Set([ ]); function isFullscreenDisplayRoute(pathname: string): boolean { - return FULLSCREEN_DISPLAY_ROUTES.has(pathname); + return ( + FULLSCREEN_DISPLAY_ROUTES.has(pathname) || + WORKFLOW_ACTIVITY_VNEXT_ROUTE.test(pathname) + ); +} + +function isWorkflowActivityVNextRoute(pathname: string): boolean { + return WORKFLOW_ACTIVITY_VNEXT_ROUTE.test(pathname); } function isStudioHostRoute(pathname: string): boolean { @@ -703,6 +712,7 @@ export const layout = ({ const search = window.location.search; const collapseForRoute = shouldCollapseLayout(pathname, search); const fullscreenDisplayRoute = isFullscreenDisplayRoute(pathname); + const workflowActivityVNextRoute = isWorkflowActivityVNextRoute(pathname); return { onPageChange: () => { @@ -792,7 +802,16 @@ export const layout = ({ collapsedShowTitle: false, type: "group", }, - contentStyle: fullscreenDisplayRoute + contentStyle: workflowActivityVNextRoute + ? { + background: "#ffffff", + display: "block", + height: "100vh", + minHeight: 0, + overflow: "hidden", + padding: 0, + } + : fullscreenDisplayRoute ? { background: "#09110f", display: "block", diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx index 20a59ef5e6..8133e3b28f 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/activity/ActivityPage.tsx @@ -5,9 +5,9 @@ import React from "react"; import { workflowActivityApi, WorkflowActivityApiError } from "@/shared/api/workflowActivityApi"; import { t } from "@/shared/i18n/messages"; import { history } from "@/shared/navigation/history"; -import WorkflowActivityVNextShell from "../WorkflowActivityVNextShell"; -import { buildWorkflowActivityRunHref } from "../navigation"; import { useConsoleLocation } from "../hooks/useConsoleLocation"; +import { buildWorkflowActivityRunHref } from "../navigation"; +import WorkflowActivityVNextShell from "../WorkflowActivityVNextShell"; function formatDate(value: string | null): string { if (!value) return t("workflowActivityVNext.common.unavailable", "Unavailable"); @@ -87,7 +87,7 @@ const ActivityPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => {

{t("workflowActivityVNext.activity.window", "Showing up to 100 recently observed runs. This is not a lifetime total.")}

- {runs.isPending ?

{t("workflowActivityVNext.activity.loading", "Loading observed runs")}

: runs.isError ?

{failureTitle(runs.error)}

{runs.error instanceof Error ? runs.error.message : String(runs.error)}

: filtered.length === 0 ?

{runs.data?.length ? t("workflowActivityVNext.activity.noMatch", "No matching observed runs") : t("workflowActivityVNext.activity.empty", "No observed runs")}

{t("workflowActivityVNext.activity.emptyDescription", "Activity appears only after the observatory read model returns a run.")}

:
{filtered.map((run) => )}
{t("workflowActivityVNext.activity.columnRun", "Run")}{t("workflowActivityVNext.activity.columnStatus", "Status")}{t("workflowActivityVNext.activity.columnOrigin", "Origin")}{t("workflowActivityVNext.activity.columnUpdated", "Observed update")}{t("workflowActivityVNext.activity.columnVersion", "State version")}
{run.status || t("workflowActivityVNext.common.unknown", "Unknown")}{run.runOrigin || t("workflowActivityVNext.common.unknown", "Unknown")}{formatDate(run.updatedAtUtc)}{run.stateVersion}
} + {runs.isPending ?

{t("workflowActivityVNext.activity.loading", "Loading observed runs")}

: runs.isError ?

{failureTitle(runs.error)}

{runs.error instanceof Error ? runs.error.message : String(runs.error)}

: filtered.length === 0 ?

{runs.data?.length ? t("workflowActivityVNext.activity.noMatch", "No matching observed runs") : t("workflowActivityVNext.activity.empty", "No observed runs")}

{t("workflowActivityVNext.activity.emptyDescription", "Activity appears only after the observatory read model returns a run.")}

:
{filtered.map((run) => )}
{t("workflowActivityVNext.activity.columnRun", "Run")}{t("workflowActivityVNext.activity.columnStatus", "Status")}{t("workflowActivityVNext.activity.columnOrigin", "Origin")}{t("workflowActivityVNext.activity.columnUpdated", "Observed update")}{t("workflowActivityVNext.activity.columnVersion", "State version")}
{run.status || t("workflowActivityVNext.common.unknown", "Unknown")}{run.runOrigin || t("workflowActivityVNext.common.unknown", "Unknown")}{formatDate(run.updatedAtUtc)}{run.stateVersion}
} ); }; diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts index 02f343ab06..fefa2a125d 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts @@ -19,7 +19,8 @@ export const workflowActivityVNextCss = ` display: grid; font-family: "Avenir Next", "Segoe UI", sans-serif; grid-template-columns: 216px minmax(0, 1fr); - min-height: calc(100vh - 56px); + height: 100vh; + min-height: 0; } .wa-vnext * { box-sizing: border-box; letter-spacing: 0; } .wa-vnext__rail { @@ -28,7 +29,8 @@ export const workflowActivityVNextCss = ` color: #fff; display: flex; flex-direction: column; - min-height: calc(100vh - 56px); + height: 100vh; + min-height: 0; padding: 22px 12px 18px; } .wa-vnext__brand { padding: 0 12px 28px; } @@ -45,7 +47,7 @@ export const workflowActivityVNextCss = ` .wa-vnext__rail-foot { border-top: 1px solid #344054; margin-top: auto; min-width: 0; padding: 14px 12px 0; } .wa-vnext__rail-foot span { color: #98a2b3; font-size: 12px; } .wa-vnext__rail-foot strong { display: block; font: 12px ui-monospace, monospace; margin-top: 5px; overflow-wrap: anywhere; } -.wa-vnext__main { background: var(--wa-surface); min-width: 0; } +.wa-vnext__main { background: var(--wa-surface); min-height: 0; min-width: 0; overflow: auto; } .wa-vnext__mobile-nav { display: none; } .wa-vnext__header { align-items: center; border-bottom: 1px solid var(--wa-line); display: flex; gap: 18px; justify-content: space-between; min-height: 84px; padding: 18px 30px; } .wa-vnext__header h1 { font-size: 22px; line-height: 1.25; margin: 0; } @@ -58,6 +60,7 @@ export const workflowActivityVNextCss = ` .wa-vnext__table th { background: var(--wa-subtle); border-bottom: 1px solid var(--wa-line); color: var(--wa-muted); font: 11px ui-monospace, monospace; height: 42px; padding: 0 12px; text-align: left; text-transform: uppercase; } .wa-vnext__table td { border-bottom: 1px solid var(--wa-line); height: 72px; padding: 10px 12px; vertical-align: middle; } .wa-vnext__table tr:last-child td { border-bottom: 0; } +.wa-vnext__run-link { max-width: 100%; min-width: 0; overflow: hidden; } .wa-vnext__title { display: block; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wa-vnext__sub { color: var(--wa-muted); display: block; font-size: 12px; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wa-vnext__status { align-items: center; border: 1px solid currentColor; border-radius: 5px; display: inline-flex; font-size: 12px; font-weight: 650; gap: 6px; min-height: 26px; padding: 0 9px; white-space: nowrap; } @@ -83,7 +86,8 @@ export const workflowActivityVNextCss = ` .wa-vnext { grid-template-columns: 1fr; } .wa-vnext__rail { display: none; } .wa-vnext__mobile-nav { background: var(--wa-sidebar); display: flex; gap: 4px; overflow-x: auto; padding: 8px 10px; } - .wa-vnext__mobile-nav .wa-vnext__nav-button { flex: 1 0 auto; justify-content: center; min-height: 38px; } + .wa-vnext__mobile-nav .wa-vnext__nav { display: flex; gap: 4px; min-width: max-content; width: 100%; } + .wa-vnext__mobile-nav .wa-vnext__nav-button { flex: 1 0 auto; justify-content: center; min-height: 38px; white-space: nowrap; } .wa-vnext__split { grid-template-columns: 1fr; } } @media (max-width: 600px) { @@ -92,6 +96,23 @@ export const workflowActivityVNextCss = ` .wa-vnext__content { padding: 18px 16px 44px; } .wa-vnext__toolbar { align-items: stretch; flex-direction: column; } .wa-vnext__toolbar .ant-input-affix-wrapper { width: 100% !important; } + .wa-vnext__activity-table { border: 0; overflow: visible; } + .wa-vnext__activity-table .wa-vnext__table { display: block; min-width: 0; } + .wa-vnext__activity-table thead { display: none; } + .wa-vnext__activity-table tbody { border: 1px solid var(--wa-line); display: block; } + .wa-vnext__activity-table tr { + display: grid; + gap: 8px 12px; + grid-template-columns: minmax(0, 1fr) auto; + padding: 14px; + } + .wa-vnext__activity-table tr:not(:last-child) { border-bottom: 1px solid var(--wa-line); } + .wa-vnext__activity-table td { border: 0; height: auto; min-width: 0; padding: 0; } + .wa-vnext__activity-table td:nth-child(1) { grid-column: 1 / -1; grid-row: 2; } + .wa-vnext__activity-table td:nth-child(2) { grid-column: 1; grid-row: 1; } + .wa-vnext__activity-table td:nth-child(3) { color: var(--wa-muted); font-size: 12px; grid-column: 1; grid-row: 3; } + .wa-vnext__activity-table td:nth-child(4) { color: var(--wa-muted); font: 12px ui-monospace, monospace; grid-column: 2; grid-row: 1; white-space: nowrap; } + .wa-vnext__activity-table td:nth-child(5) { color: var(--wa-muted); font: 12px ui-monospace, monospace; grid-column: 2; grid-row: 3; text-align: right; } } @media (prefers-reduced-motion: reduce) { .wa-vnext *, .wa-vnext *::before, .wa-vnext *::after { scroll-behavior: auto !important; transition: none !important; } } `; From a8cf16983f31fed79129b4afb06df0125fab02c8 Mon Sep 17 00:00:00 2001 From: Abigail Deng Date: Wed, 5 Aug 2026 10:38:37 +0800 Subject: [PATCH 03/18] Refine Workflow Activity vNext settings layout --- .../src/app.layout.test.ts | 4 +- apps/aevatar-console-web/src/app.tsx | 10 +- .../workflowActivityVNextMessages.en-US.ts | 25 ++- .../workflowActivityVNextMessages.zh-CN.ts | 25 ++- .../WorkflowActivityVNextShell.tsx | 8 +- .../workflow-activity-vnext/index.test.tsx | 47 ++++- .../settings/SettingsPage.tsx | 187 +++++++++++++++++- .../pages/workflow-activity-vnext/styles.ts | 55 +++++- 8 files changed, 321 insertions(+), 40 deletions(-) diff --git a/apps/aevatar-console-web/src/app.layout.test.ts b/apps/aevatar-console-web/src/app.layout.test.ts index ba38dd6437..0bda537ffc 100644 --- a/apps/aevatar-console-web/src/app.layout.test.ts +++ b/apps/aevatar-console-web/src/app.layout.test.ts @@ -110,9 +110,11 @@ describe("layout menu collapse behavior", () => { expect(actionsRender?.({}, {})).toEqual([]); expect(runtimeLayout.contentStyle).toMatchObject({ background: "#ffffff", - height: "100vh", + height: "auto", + inset: 0, overflow: "hidden", padding: 0, + position: "fixed", }); }); diff --git a/apps/aevatar-console-web/src/app.tsx b/apps/aevatar-console-web/src/app.tsx index 62f2f506c4..bd611d10ef 100644 --- a/apps/aevatar-console-web/src/app.tsx +++ b/apps/aevatar-console-web/src/app.tsx @@ -16,7 +16,6 @@ import { import { getNyxIDRuntimeConfig } from "./shared/auth/config"; import { buildAuthInitialState, - loadRestorableAuthSession, loadStoredAuthSession, sanitizeReturnTo, } from "./shared/auth/session"; @@ -234,7 +233,9 @@ function setLiveOpsAttentionSnapshot(next: LiveOpsAttentionSnapshot): void { } liveOpsAttentionSnapshot = next; - liveOpsAttentionListeners.forEach((listener) => listener()); + liveOpsAttentionListeners.forEach((listener) => { + listener(); + }); } function buildLiveOpsAttentionCandidateKey( @@ -806,10 +807,13 @@ export const layout = ({ ? { background: "#ffffff", display: "block", - height: "100vh", + height: "auto", + inset: 0, minHeight: 0, overflow: "hidden", padding: 0, + position: "fixed", + width: "100%", } : fullscreenDisplayRoute ? { diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts index 4db11c4aea..70acb92f8c 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts @@ -166,20 +166,26 @@ const workflowActivityVNextMessages = { "workflowActivityVNext.scope": "Scope", "workflowActivityVNext.settings.accepted": "Update accepted; observing authoritative settings", "workflowActivityVNext.settings.account": "Account", - "workflowActivityVNext.settings.accountLoading": "Loading account session", + "workflowActivityVNext.settings.accountDescription": "Identity and service access for this browser session.", + "workflowActivityVNext.settings.accountLoading": "Loading account session…", "workflowActivityVNext.settings.accountUnavailable": "Account session unavailable", "workflowActivityVNext.settings.activeRuntime": "Active runtime URL", "workflowActivityVNext.settings.advanced": "Advanced", + "workflowActivityVNext.settings.advancedDescription": "Effective runtime values used by this browser session.", "workflowActivityVNext.settings.ai": "AI defaults", + "workflowActivityVNext.settings.aiDescription": "Choose the service and model used by new Chat, Studio, and global tool sessions without an override.", "workflowActivityVNext.settings.authenticated": "Authenticated", "workflowActivityVNext.settings.catalogUnavailable": "The provider catalogue is unavailable. Saved values remain visible, but unverifiable choices cannot be invented.", + "workflowActivityVNext.settings.catalogUnavailableTitle": "Provider catalogue unavailable", + "workflowActivityVNext.settings.defaultModel": "Default model", + "workflowActivityVNext.settings.defaultModelHelp": "Leave unset to use the service default.", "workflowActivityVNext.settings.delayed": "Update accepted but not yet observed", - "workflowActivityVNext.settings.description": "Personal AI defaults, session identity, and effective runtime context.", + "workflowActivityVNext.settings.description": "Personal defaults and access.", "workflowActivityVNext.settings.discard": "Discard changes", "workflowActivityVNext.settings.email": "Email", "workflowActivityVNext.settings.expires": "Expires", "workflowActivityVNext.settings.failed": "AI defaults could not be saved", - "workflowActivityVNext.settings.llmLoading": "Loading AI defaults", + "workflowActivityVNext.settings.llmLoading": "Loading AI defaults…", "workflowActivityVNext.settings.llmUnavailable": "AI defaults unavailable", "workflowActivityVNext.settings.groups": "Groups", "workflowActivityVNext.settings.localRuntime": "Local runtime URL", @@ -188,20 +194,27 @@ const workflowActivityVNextMessages = { "workflowActivityVNext.settings.notAccepted": "The settings update was not accepted.", "workflowActivityVNext.settings.observed": "AI defaults observed", "workflowActivityVNext.settings.providerDefault": "Provider default", + "workflowActivityVNext.settings.preferredService": "Preferred service", + "workflowActivityVNext.settings.preferredServiceHelp": "Gateway or an exact connected service.", "workflowActivityVNext.settings.provider": "Provider", "workflowActivityVNext.settings.remoteRuntime": "Remote runtime URL", "workflowActivityVNext.settings.route": "AI route", "workflowActivityVNext.settings.roles": "Roles", - "workflowActivityVNext.settings.runtimeLoading": "Loading effective runtime", + "workflowActivityVNext.settings.runtimeLoading": "Loading effective runtime…", "workflowActivityVNext.settings.runtimeMode": "Runtime mode", "workflowActivityVNext.settings.runtimeUnavailable": "Effective runtime unavailable", - "workflowActivityVNext.settings.save": "Save AI defaults", - "workflowActivityVNext.settings.saving": "Saving AI defaults", + "workflowActivityVNext.settings.retryGuidance": "Refresh this section to try loading the authoritative settings again.", + "workflowActivityVNext.settings.save": "Save changes", + "workflowActivityVNext.settings.saving": "Saving AI defaults…", "workflowActivityVNext.settings.selectionReady": "Saved selection is ready", + "workflowActivityVNext.settings.sectionsAria": "Settings sections", "workflowActivityVNext.settings.scopeSource": "Scope source", "workflowActivityVNext.settings.subject": "Subject", "workflowActivityVNext.settings.systemDefault": "System default", + "workflowActivityVNext.settings.technicalDetails": "Technical details", "workflowActivityVNext.settings.title": "Settings", + "workflowActivityVNext.settings.unsaved": "Unsaved changes", + "workflowActivityVNext.settings.unsavedDescription": "Your AI defaults have not been saved.", "workflowActivityVNext.state.forbidden": "Scope access forbidden", "workflowActivityVNext.state.unauthorized": "Authentication required", "workflowActivityVNext.unavailable.body": "The requested capability is not currently available from the frontend route.", diff --git a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts index 0b171f5ea9..bb3e5b3e61 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts @@ -169,20 +169,26 @@ const workflowActivityVNextMessages: Record = "workflowActivityVNext.scope": "Scope", "workflowActivityVNext.settings.accepted": "更新已接受,正在观测权威设置", "workflowActivityVNext.settings.account": "账户", - "workflowActivityVNext.settings.accountLoading": "正在加载账户会话", + "workflowActivityVNext.settings.accountDescription": "此浏览器会话的身份与服务访问权限。", + "workflowActivityVNext.settings.accountLoading": "正在加载账户会话…", "workflowActivityVNext.settings.accountUnavailable": "账户会话不可用", "workflowActivityVNext.settings.activeRuntime": "当前 Runtime URL", "workflowActivityVNext.settings.advanced": "高级", + "workflowActivityVNext.settings.advancedDescription": "此浏览器会话当前使用的 Runtime 配置值。", "workflowActivityVNext.settings.ai": "AI 默认设置", + "workflowActivityVNext.settings.aiDescription": "选择新的 Chat、Studio 和全局工具会话在没有覆盖设置时使用的服务与模型。", "workflowActivityVNext.settings.authenticated": "已认证", "workflowActivityVNext.settings.catalogUnavailable": "Provider catalogue 不可用。已保存值仍可见,但不能虚构无法验证的选项。", + "workflowActivityVNext.settings.catalogUnavailableTitle": "Provider catalogue 不可用", + "workflowActivityVNext.settings.defaultModel": "默认模型", + "workflowActivityVNext.settings.defaultModelHelp": "留空时使用服务默认模型。", "workflowActivityVNext.settings.delayed": "更新已接受但尚未观测到", - "workflowActivityVNext.settings.description": "个人 AI 默认设置、会话身份与当前 Runtime 上下文。", + "workflowActivityVNext.settings.description": "个人默认设置与访问权限。", "workflowActivityVNext.settings.discard": "放弃更改", "workflowActivityVNext.settings.email": "邮箱", "workflowActivityVNext.settings.expires": "过期时间", "workflowActivityVNext.settings.failed": "无法保存 AI 默认设置", - "workflowActivityVNext.settings.llmLoading": "正在加载 AI 默认设置", + "workflowActivityVNext.settings.llmLoading": "正在加载 AI 默认设置…", "workflowActivityVNext.settings.llmUnavailable": "AI 默认设置不可用", "workflowActivityVNext.settings.groups": "用户组", "workflowActivityVNext.settings.localRuntime": "本地 Runtime URL", @@ -191,20 +197,27 @@ const workflowActivityVNextMessages: Record = "workflowActivityVNext.settings.notAccepted": "设置更新未被接受。", "workflowActivityVNext.settings.observed": "已观测到 AI 默认设置", "workflowActivityVNext.settings.providerDefault": "Provider 默认值", + "workflowActivityVNext.settings.preferredService": "首选服务", + "workflowActivityVNext.settings.preferredServiceHelp": "Gateway 或一个明确连接的服务。", "workflowActivityVNext.settings.provider": "身份 Provider", "workflowActivityVNext.settings.remoteRuntime": "远端 Runtime URL", "workflowActivityVNext.settings.route": "AI 路由", "workflowActivityVNext.settings.roles": "角色", - "workflowActivityVNext.settings.runtimeLoading": "正在加载当前 Runtime", + "workflowActivityVNext.settings.runtimeLoading": "正在加载当前 Runtime…", "workflowActivityVNext.settings.runtimeMode": "Runtime 模式", "workflowActivityVNext.settings.runtimeUnavailable": "当前 Runtime 不可用", - "workflowActivityVNext.settings.save": "保存 AI 默认设置", - "workflowActivityVNext.settings.saving": "正在保存 AI 默认设置", + "workflowActivityVNext.settings.retryGuidance": "刷新此部分以重新加载权威设置。", + "workflowActivityVNext.settings.save": "保存更改", + "workflowActivityVNext.settings.saving": "正在保存 AI 默认设置…", "workflowActivityVNext.settings.selectionReady": "已保存的选择可用", + "workflowActivityVNext.settings.sectionsAria": "设置分类", "workflowActivityVNext.settings.scopeSource": "Scope 来源", "workflowActivityVNext.settings.subject": "Subject", "workflowActivityVNext.settings.systemDefault": "系统默认值", + "workflowActivityVNext.settings.technicalDetails": "技术详情", "workflowActivityVNext.settings.title": "设置", + "workflowActivityVNext.settings.unsaved": "未保存的更改", + "workflowActivityVNext.settings.unsavedDescription": "AI 默认设置尚未保存。", "workflowActivityVNext.state.forbidden": "无权访问此 Scope", "workflowActivityVNext.state.unauthorized": "需要登录", "workflowActivityVNext.unavailable.body": "当前前端路由尚不支持所请求的能力。", diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx index aa1b0101a1..7058233071 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx @@ -24,9 +24,9 @@ type ShellProps = { }; const items = [ - { key: "workflows" as const, icon: , labelKey: "workflows", fallback: "Workflows" }, - { key: "activity" as const, icon: , labelKey: "activity", fallback: "Activity" }, - { key: "settings" as const, icon: , labelKey: "settings", fallback: "Settings" }, + { key: "workflows" as const, icon:
-
-
+
+
-
{t("pages.studio.studiobuildpanels.dry.run.4", "Dry-run")}
- {t("pages.studio.studiobuildpanels.workflow.draft.run.2", "Workflow draft run")} +
+ {t('pages.studio.studiobuildpanels.dry.run.4', 'Dry-run')} +
+ + {t( + 'pages.studio.studiobuildpanels.workflow.draft.run.2', + 'Workflow draft run', + )} +
- - {t("pages.studio.studiobuildpanels.draft.input", "Draft input")} + + {t('pages.studio.studiobuildpanels.draft.input', 'Draft input')} +
- {t("pages.studio.studiobuildpanels.route.label", "Route: ")}{dryRunRouteLabel || t("pages.studio.studiobuildpanels.config.default.2", "Config default")} + {t('pages.studio.studiobuildpanels.route.label', 'Route: ')} + {dryRunRouteLabel || + t( + 'pages.studio.studiobuildpanels.config.default.2', + 'Config default', + )}
- {t("pages.studio.studiobuildpanels.model.label", "Model: ")}{dryRunModelLabel || t("pages.studio.studiobuildpanels.use.configured.default.2", "Use configured default")} + {t('pages.studio.studiobuildpanels.model.label', 'Model: ')} + {dryRunModelLabel || + t( + 'pages.studio.studiobuildpanels.use.configured.default.2', + 'Use configured default', + )}
{dryRunBlockedReason ? ( @@ -1853,7 +2162,11 @@ export const StudioWorkflowBuildPanel: React.FC = type="link" onClick={onOpenRunSetup} > - {t("pages.studio.studiobuildpanels.connect.provider.2", "Connect provider")} + {t( + 'pages.studio.studiobuildpanels.connect.provider.2', + 'Connect provider', + )} + ) : undefined } message={dryRunBlockedReason} @@ -1862,9 +2175,15 @@ export const StudioWorkflowBuildPanel: React.FC = /> ) : null} onRunPromptChange(event.target.value)} /> @@ -1881,7 +2200,8 @@ export const StudioWorkflowBuildPanel: React.FC = } onClick={() => void handleRun()} > - {t("pages.studio.studiobuildpanels.run.4", "Run")} + {t('pages.studio.studiobuildpanels.run.4', 'Run')} + + {t( + 'pages.studio.studiobuildpanels.load.sample.input', + 'Load sample input', + )} + {workflowRunError ? ( ) : null}
-
{t("pages.studio.studiobuildpanels.output.4", "Output")}
-
{renderRunOutput(runState)}
+
+ {t('pages.studio.studiobuildpanels.output.4', 'Output')} +
+
+            {renderRunOutput(runState)}
+          
{renderRunSummary(runState) ? (
- {t("pages.studio.studiobuildpanels.debug.details.3", "Debug details")} -
{renderRunSummary(runState)}
+ + {t( + 'pages.studio.studiobuildpanels.debug.details.3', + 'Debug details', + )} + +
+              {renderRunSummary(runState)}
+            
) : null}
@@ -1932,10 +2267,16 @@ export type StudioScriptBuildPanelProps = { readonly onCreateScriptDraft?: () => void; readonly onRefreshScripts?: () => Promise | unknown; readonly onContinueToBind: () => void; - readonly onRegisterLeaveGuard?: (guard: (() => Promise) | null) => void; - readonly onScriptBuildStateChange?: (state: StudioScriptBuildState | null) => void; + readonly onRegisterLeaveGuard?: ( + guard: (() => Promise) | null, + ) => void; + readonly onScriptBuildStateChange?: ( + state: StudioScriptBuildState | null, + ) => void; readonly pendingScriptDraft?: StudioPendingScriptDraft | null; - readonly onPendingScriptDraftChange?: (draft: StudioPendingScriptDraft | null) => void; + readonly onPendingScriptDraftChange?: ( + draft: StudioPendingScriptDraft | null, + ) => void; readonly onScriptDraftSaved?: (scriptId: string) => void; }; @@ -2008,7 +2349,8 @@ function buildAppliedScriptDetail( script: summary, source: { sourceText, - definitionActorId: summary.definitionActorId || acceptedScript.definitionActorId, + definitionActorId: + summary.definitionActorId || acceptedScript.definitionActorId, revision: summary.activeRevision || acceptedScript.revisionId, sourceHash: summary.activeSourceHash || acceptedScript.sourceHash, }, @@ -2089,16 +2431,19 @@ export const StudioScriptBuildPanel: React.FC = ({ const [runOutput, setRunOutput] = React.useState( 'Run the current script draft to inspect the draft-run result here.', ); - const [lastRunResult, setLastRunResult] = React.useState(null); + const [lastRunResult, setLastRunResult] = + React.useState(null); const [leaveDialogOpen, setLeaveDialogOpen] = React.useState(false); - const leaveResolverRef = React.useRef<((value: boolean) => void) | null>(null); + const leaveResolverRef = React.useRef<((value: boolean) => void) | null>( + null, + ); const saveObservationTimerRef = React.useRef(null); const saveObservationTokenRef = React.useRef(0); const activeScriptIdRef = React.useRef(''); const availableScripts = React.useMemo( () => - (scriptsQuery.data ?? []).filter( - (detail): detail is ScopedScriptDetail => Boolean(detail.available && detail.script), + (scriptsQuery.data ?? []).filter((detail): detail is ScopedScriptDetail => + Boolean(detail.available && detail.script), ), [scriptsQuery.data], ); @@ -2110,7 +2455,10 @@ export const StudioScriptBuildPanel: React.FC = ({ [pendingScriptDraft, scopeId, selectedScriptId], ); const selectedCatalogScript = React.useMemo( - () => availableScripts.find((detail) => detail.script?.scriptId === selectedScriptId) || null, + () => + availableScripts.find( + (detail) => detail.script?.scriptId === selectedScriptId, + ) || null, [availableScripts, selectedScriptId], ); const selectedObservedAppliedScript = React.useMemo( @@ -2128,9 +2476,12 @@ export const StudioScriptBuildPanel: React.FC = ({ availableScripts[0] || null; activeScriptIdRef.current = activeScript?.script?.scriptId || ''; - const activeScriptIsDraft = Boolean(pendingScriptDetail && activeScript === pendingScriptDetail); + const activeScriptIsDraft = Boolean( + pendingScriptDetail && activeScript === pendingScriptDetail, + ); const activeScriptIsObserved = Boolean( - selectedObservedAppliedScript && activeScript === selectedObservedAppliedScript, + selectedObservedAppliedScript && + activeScript === selectedObservedAppliedScript, ); const persistedSource = React.useMemo( () => activeScript?.source?.sourceText || '', @@ -2148,7 +2499,11 @@ export const StudioScriptBuildPanel: React.FC = ({ [scriptPackage, selectedFilePath], ); const editorMarkers = React.useMemo( - () => mapScriptMarkers(validationResult?.diagnostics, selectedPackageEntry?.path || ''), + () => + mapScriptMarkers( + validationResult?.diagnostics, + selectedPackageEntry?.path || '', + ), [selectedPackageEntry?.path, validationResult?.diagnostics], ); const packageEntries = React.useMemo( @@ -2165,15 +2520,18 @@ export const StudioScriptBuildPanel: React.FC = ({ return; } - const nextPackage = activeScriptIsDraft && !activeScript.source?.sourceText - ? createScriptStarterPackage() - : deserializePersistedSource(activeScript.source?.sourceText || ''); + const nextPackage = + activeScriptIsDraft && !activeScript.source?.sourceText + ? createScriptStarterPackage() + : deserializePersistedSource(activeScript.source?.sourceText || ''); const nextEntry = getSelectedPackageEntry(nextPackage, nextPackage.entrySourcePath) || getSelectedPackageEntry(nextPackage, '') || null; setScriptPackage(nextPackage); - setSelectedFilePath(nextEntry?.path || nextPackage.entrySourcePath || 'Behavior.cs'); + setSelectedFilePath( + nextEntry?.path || nextPackage.entrySourcePath || 'Behavior.cs', + ); setValidationResult(null); setValidationError(''); setFocusedDiagnostic(null); @@ -2194,12 +2552,22 @@ export const StudioScriptBuildPanel: React.FC = ({ ]); React.useEffect(() => { - if (selectedScriptId || pendingScriptDraft?.scriptId || !availableScripts[0]?.script?.scriptId) { + const firstAvailableScriptId = availableScripts[0]?.script?.scriptId; + if ( + selectedScriptId || + pendingScriptDraft?.scriptId || + !firstAvailableScriptId + ) { return; } - onSelectScriptId(availableScripts[0].script!.scriptId); - }, [availableScripts, onSelectScriptId, pendingScriptDraft?.scriptId, selectedScriptId]); + onSelectScriptId(firstAvailableScriptId); + }, [ + availableScripts, + onSelectScriptId, + pendingScriptDraft?.scriptId, + selectedScriptId, + ]); const cancelSaveObservationPoll = React.useCallback(() => { saveObservationTokenRef.current += 1; @@ -2248,16 +2616,15 @@ export const StudioScriptBuildPanel: React.FC = ({ : validationError ? 'invalid' : 'unknown'; - const effectiveSaveStatus: StudioScriptBuildState['saveStatus'] = - isDirty - ? saveStatus === 'failed' - ? 'failed' - : 'idle' - : saveStatus === 'accepted' || saveStatus === 'failed' - ? saveStatus - : activeScript?.script?.activeRevision - ? 'applied' - : saveStatus; + const effectiveSaveStatus: StudioScriptBuildState['saveStatus'] = isDirty + ? saveStatus === 'failed' + ? 'failed' + : 'idle' + : saveStatus === 'accepted' || saveStatus === 'failed' + ? saveStatus + : activeScript?.script?.activeRevision + ? 'applied' + : saveStatus; const scriptReadyToBind = Boolean( activeScript?.script?.scriptId && !isDirty && @@ -2267,42 +2634,43 @@ export const StudioScriptBuildPanel: React.FC = ({ const lifecycleStatus = !activeScript?.script?.scriptId ? 'No script' : isDirty && effectiveSaveStatus !== 'failed' - ? 'Unsaved edits' - : saveObservationStatus === 'accepted' || effectiveSaveStatus === 'accepted' - ? 'Save accepted' - : saveObservationStatus === 'pending' - ? 'Waiting for catalog' - : effectiveSaveStatus === 'failed' - ? 'Save needs attention' - : activeScriptIsDraft - ? 'Draft' - : scriptReadyToBind - ? 'Catalog applied' - : 'Catalog script'; + ? 'Unsaved edits' + : saveObservationStatus === 'accepted' || + effectiveSaveStatus === 'accepted' + ? 'Save accepted' + : saveObservationStatus === 'pending' + ? 'Waiting for catalog' + : effectiveSaveStatus === 'failed' + ? 'Save needs attention' + : activeScriptIsDraft + ? 'Draft' + : scriptReadyToBind + ? 'Catalog applied' + : 'Catalog script'; const lifecycleStatusColor = lifecycleStatus === 'Catalog applied' ? 'green' : lifecycleStatus === 'Save needs attention' ? 'red' - : lifecycleStatus === 'Waiting for catalog' || lifecycleStatus === 'Save accepted' + : lifecycleStatus === 'Waiting for catalog' || + lifecycleStatus === 'Save accepted' ? 'gold' : activeScriptIsDraft ? 'blue' : 'default'; - const bindReadinessLabel = - !activeScript?.script?.scriptId - ? 'Create or select a script' - : validationStatus === 'invalid' - ? 'Fix validation errors' - : isDirty && validationStatus !== 'valid' - ? 'Validate current source' - : isDirty - ? 'Save script' - : effectiveSaveStatus === 'accepted' - ? 'Waiting for catalog' - : scriptReadyToBind - ? 'Ready to bind' - : 'Save script'; + const bindReadinessLabel = !activeScript?.script?.scriptId + ? 'Create or select a script' + : validationStatus === 'invalid' + ? 'Fix validation errors' + : isDirty && validationStatus !== 'valid' + ? 'Validate current source' + : isDirty + ? 'Save script' + : effectiveSaveStatus === 'accepted' + ? 'Waiting for catalog' + : scriptReadyToBind + ? 'Ready to bind' + : 'Save script'; const saveObservationInFlight = saveObservationStatus === 'accepted' || saveObservationStatus === 'pending'; const saveNoticeType = @@ -2329,7 +2697,8 @@ export const StudioScriptBuildPanel: React.FC = ({ onScriptBuildStateChange?.({ scriptId: activeScript.script.scriptId, - displayName: pendingScriptDraft?.displayName || activeScript.script.scriptId, + displayName: + pendingScriptDraft?.displayName || activeScript.script.scriptId, scriptRevision: activeScript.source?.revision || activeScript.script.activeRevision || @@ -2392,7 +2761,12 @@ export const StudioScriptBuildPanel: React.FC = ({ }); } }, - [activeScriptIsDraft, cancelSaveObservationPoll, onPendingScriptDraftChange, pendingScriptDraft], + [ + activeScriptIsDraft, + cancelSaveObservationPoll, + onPendingScriptDraftChange, + pendingScriptDraft, + ], ); const handleValidate = React.useCallback(async () => { @@ -2442,7 +2816,7 @@ export const StudioScriptBuildPanel: React.FC = ({ setSaveObservationStatus('applied'); setSaveStatus('applied'); setSaveNotice( - t("pages.studio.studiobuildpanels.save.applied", "Save applied."), + t('pages.studio.studiobuildpanels.save.applied', 'Save applied.'), ); }, [onRefreshScripts, onScriptDraftSaved, scopeId], @@ -2497,7 +2871,10 @@ export const StudioScriptBuildPanel: React.FC = ({ setSaveStatus('failed'); setSaveNotice( observation.message || - t("pages.studio.studiobuildpanels.save.rejected", "Save rejected."), + t( + 'pages.studio.studiobuildpanels.save.rejected', + 'Save rejected.', + ), ); return; } @@ -2508,8 +2885,8 @@ export const StudioScriptBuildPanel: React.FC = ({ saveObservationTimerRef.current = null; setSaveNotice( t( - "pages.studio.studiobuildpanels.save.accepted.waiting.for.catalog", - "Save accepted. Still waiting for catalog; use Refresh catalog to check again.", + 'pages.studio.studiobuildpanels.save.accepted.waiting.for.catalog', + 'Save accepted. Still waiting for catalog; use Refresh catalog to check again.', ), ); return; @@ -2517,8 +2894,8 @@ export const StudioScriptBuildPanel: React.FC = ({ setSaveNotice( t( - "pages.studio.studiobuildpanels.save.accepted.checking.again", - "Save accepted. Waiting for catalog; checking again in {value1}s.", + 'pages.studio.studiobuildpanels.save.accepted.checking.again', + 'Save accepted. Waiting for catalog; checking again in {value1}s.', { value1: Math.round(nextDelay / 1000) }, ), ); @@ -2548,7 +2925,9 @@ export const StudioScriptBuildPanel: React.FC = ({ const handleSave = React.useCallback(async () => { if (!scopeId || !activeScript?.script?.scriptId) { - setSaveNotice('Resolve the current workspace and select a script before saving.'); + setSaveNotice( + 'Resolve the current workspace and select a script before saving.', + ); return; } @@ -2631,12 +3010,20 @@ export const StudioScriptBuildPanel: React.FC = ({ return; } - const nextPackage = removePackageFile(scriptPackage, selectedPackageEntry.path); + const nextPackage = removePackageFile( + scriptPackage, + selectedPackageEntry.path, + ); const nextEntry = getSelectedPackageEntry(nextPackage, nextPackage.entrySourcePath) || getSelectedPackageEntry(nextPackage, ''); commitScriptPackage(nextPackage, nextEntry?.path); - }, [commitScriptPackage, packageEntries.length, scriptPackage, selectedPackageEntry]); + }, [ + commitScriptPackage, + packageEntries.length, + scriptPackage, + selectedPackageEntry, + ]); const handleSetEntrySource = React.useCallback(() => { if (!selectedPackageEntry || selectedPackageEntry.kind !== 'csharp') { @@ -2651,7 +3038,9 @@ export const StudioScriptBuildPanel: React.FC = ({ const handlePromoteEvolution = React.useCallback(async () => { if (!scopeId || !activeScript?.script?.scriptId) { - setPromotionNotice('Resolve the current workspace and script before proposing evolution.'); + setPromotionNotice( + 'Resolve the current workspace and script before proposing evolution.', + ); return; } @@ -2672,7 +3061,8 @@ export const StudioScriptBuildPanel: React.FC = ({ setPromotionNotice( decision.accepted ? 'Promotion accepted.' - : decision.failureReason || `Promotion ${decision.status || 'not accepted'}.`, + : decision.failureReason || + `Promotion ${decision.status || 'not accepted'}.`, ); } catch (error) { setPromotionNotice(describeError(error)); @@ -2690,7 +3080,9 @@ export const StudioScriptBuildPanel: React.FC = ({ const handleRun = React.useCallback(async () => { if (!scopeId || !activeScript?.script?.scriptId) { - setRunOutput('Resolve the current workspace and select a script before running.'); + setRunOutput( + 'Resolve the current workspace and select a script before running.', + ); return; } @@ -2728,16 +3120,26 @@ export const StudioScriptBuildPanel: React.FC = ({ if (scriptsQuery.isLoading) { return ( -
+
- {t("pages.studio.studiobuildpanels.loading.workspace.scripts.2", "Loading workspace scripts...")} + {t( + 'pages.studio.studiobuildpanels.loading.workspace.scripts.2', + 'Loading workspace scripts...', + )} +
); } if (scriptsQuery.isError) { return ( -
+
= ({ return (
-
{t("pages.studio.studiobuildpanels.script.source.2", "Script Source")}
+
+ {t( + 'pages.studio.studiobuildpanels.script.source.2', + 'Script Source', + )} +
{hasActiveScript - ? t("pages.studio.studiobuildpanels.script.mode.script.draft", "Script mode does one thing: iterate on the current script draft's typed source, lint results, and dry-run implementation.") - : t("pages.studio.studiobuildpanels.create.script.to.start.editing.saved", "Create a script to start editing. Saved workspace scripts appear here when this catalog has one.")} + ? t( + 'pages.studio.studiobuildpanels.script.mode.script.draft', + "Script mode does one thing: iterate on the current script draft's typed source, lint results, and dry-run implementation.", + ) + : t( + 'pages.studio.studiobuildpanels.create.script.to.start.editing.saved', + 'Create a script to start editing. Saved workspace scripts appear here when this catalog has one.', + )}
-
+
{hasActiveScript ? ( <> @@ -2774,16 +3196,25 @@ export const StudioScriptBuildPanel: React.FC = ({ ) : null} {hasActiveScript ? ( commitScriptPackage( - updateEntryBehaviorTypeName(scriptPackage, event.target.value), + updateEntryBehaviorTypeName( + scriptPackage, + event.target.value, + ), selectedPackageEntry.path, ) } @@ -2984,13 +3499,21 @@ export const StudioScriptBuildPanel: React.FC = ({ disabled={selectedPackageEntry.kind !== 'csharp'} onClick={handleSetEntrySource} > - {t("pages.studio.studiobuildpanels.set.entry.source.2", "Set entry source")} + {t( + 'pages.studio.studiobuildpanels.set.entry.source.2', + 'Set entry source', + )} +
{ @@ -3019,28 +3542,48 @@ export const StudioScriptBuildPanel: React.FC = ({ }} >
-
{t("pages.studio.studiobuildpanels.compiler.2", "Compiler")}
+
+ {t('pages.studio.studiobuildpanels.compiler.2', 'Compiler')} +
{validationResult ? validationResult.success - ? t("pages.studio.studiobuildpanels.validation.completed.without.blocking.errors.2", "Validation completed without blocking errors.") - : t("pages.studio.studiobuildpanels.validation.returned.blocking.diagnostics.2", "Validation returned blocking diagnostics.") - : t("pages.studio.studiobuildpanels.run.validate.to.refresh.compiler.diagnostics", "Run Validate to refresh compiler diagnostics.")} + ? t( + 'pages.studio.studiobuildpanels.validation.completed.without.blocking.errors.2', + 'Validation completed without blocking errors.', + ) + : t( + 'pages.studio.studiobuildpanels.validation.returned.blocking.diagnostics.2', + 'Validation returned blocking diagnostics.', + ) + : t( + 'pages.studio.studiobuildpanels.run.validate.to.refresh.compiler.diagnostics', + 'Run Validate to refresh compiler diagnostics.', + )}
{validationResult?.diagnostics?.length ? ( - {t("pages.studio.studiobuildpanels.problems.2", "Problems")}{validationResult.diagnostics.length} + {t( + 'pages.studio.studiobuildpanels.problems.2', + 'Problems', + )} + {validationResult.diagnostics.length} ) : ( - {t("pages.studio.studiobuildpanels.clean.2", "Clean")} + + {t('pages.studio.studiobuildpanels.clean.2', 'Clean')} + )}
{validationResult?.diagnostics?.length ? ( -
= ({ padding: 12, }} > -
{t("pages.studio.studiobuildpanels.diagnostics.2", "Diagnostics")}
+
+ {t( + 'pages.studio.studiobuildpanels.diagnostics.2', + 'Diagnostics', + )} +
{validationResult.diagnostics.map((diagnostic, index) => { const diagnosticKey = `${diagnostic.filePath || 'source'}:${diagnostic.startLine || 0}:${diagnostic.startColumn || 0}:${diagnostic.code || index}`; const severityColor = @@ -3083,48 +3631,88 @@ export const StudioScriptBuildPanel: React.FC = ({ textAlign: 'left', }} > - - + + {diagnostic.severity} {formatScriptDiagnosticLocation(diagnostic)} {diagnostic.code ? ( - + {diagnostic.code} ) : null} - + {diagnostic.message} ); })} -
+
) : null}
) : ( + {t('pages.studio.studiobuildpanels.add.script.2', 'Add script')} + )}
{hasActiveScript ? ( -
+
{scriptReadyToBind - ? t("pages.studio.studiobuildpanels.script.revision.is.catalog.applied.continue", "Script revision is catalog-applied. Continue to Bind to publish the callable member contract.") - : t("pages.studio.studiobuildpanels.script.build.keeps.code.editing.here", "Script Build keeps code editing here. {value1}.", { value1: bindReadinessLabel })} + ? t( + 'pages.studio.studiobuildpanels.script.revision.is.catalog.applied.continue', + 'Script revision is catalog-applied. Continue to Bind to publish the callable member contract.', + ) + : t( + 'pages.studio.studiobuildpanels.script.build.keeps.code.editing.here', + 'Script Build keeps code editing here. {value1}.', + { value1: bindReadinessLabel }, + )} + {t( + 'pages.studio.studiobuildpanels.continue.to.bind.5', + 'Continue to Bind', + )} +
) : null} @@ -3145,83 +3737,136 @@ export const StudioScriptBuildPanel: React.FC = ({ {hasActiveScript ? (