From 7539291e8b08c0c3eb0e0640ca582d195a2d4f81 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:17:52 +0800 Subject: [PATCH 01/15] Restore one-click workflow publishing --- .../workflowActivityVNextMessages.en-US.ts | 40 +- .../workflowActivityVNextMessages.zh-CN.ts | 38 +- .../workflow-activity-vnext/index.test.tsx | 483 ++---------------- .../workflows/WorkflowEditorPage.tsx | 444 ++++++---------- .../workflows/WorkflowPublishDialog.test.tsx | 254 --------- .../workflows/WorkflowPublishDialog.tsx | 410 --------------- 6 files changed, 226 insertions(+), 1443 deletions(-) delete mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowPublishDialog.test.tsx delete mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowPublishDialog.tsx 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 ba1b367a9d..b5714f4892 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts @@ -110,15 +110,10 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.editor.waitForPublishedRun': 'Wait for the published revision to become available.', 'workflowActivityVNext.publish.accepted': 'Publication accepted', - 'workflowActivityVNext.publish.approvalNotRequired': - 'No additional approval is required.', - 'workflowActivityVNext.publish.approvalRequired': - 'Approval is required before this request can run.', 'workflowActivityVNext.publish.addExecutableStep': 'Add at least one executable step before publishing.', 'workflowActivityVNext.publish.applyNodeChanges': 'Apply or discard node configuration before publishing.', - 'workflowActivityVNext.publish.backToService': 'Back', 'workflowActivityVNext.publish.blocked': 'Publish blocked · {count} issues', 'workflowActivityVNext.publish.blockedOne': 'Publish blocked · 1 issue', 'workflowActivityVNext.publish.checkAgain': 'Check again', @@ -126,57 +121,30 @@ const workflowActivityVNextMessages = { 'Publication is taking longer to appear', 'workflowActivityVNext.publish.delayedDescription': 'Check again for the latest publishing status.', - 'workflowActivityVNext.publish.destinationDescription': - 'Choose the service that will use {workflowName}.', 'workflowActivityVNext.publish.failed': "Publication couldn't be confirmed", 'workflowActivityVNext.publish.failedDescription': - 'Review the workflow or try publishing again.', + 'Resolve the error or try publishing again.', 'workflowActivityVNext.publish.forbiddenDescription': "You don't have access to check this publication.", - 'workflowActivityVNext.publish.loadingServices': 'Loading services…', - 'workflowActivityVNext.publish.noExternalRequests': - 'No external requests need review.', - 'workflowActivityVNext.publish.noServices': - 'No services are available in this workspace', 'workflowActivityVNext.publish.observed': 'Workflow published', 'workflowActivityVNext.publish.observedDescription': - 'The selected service is now using this workflow.', + 'The workflow is now published.', 'workflowActivityVNext.publish.observingDescription': - 'Checking whether the selected service is ready.', + 'Checking whether the published revision is ready.', 'workflowActivityVNext.publish.published': 'Published', 'workflowActivityVNext.publish.publishedServiceId': 'Published service ID', 'workflowActivityVNext.publish.publishing': 'Publishing', - 'workflowActivityVNext.publish.publishingTo': 'Publishing to {service}', 'workflowActivityVNext.publish.revisionId': 'Revision ID', - 'workflowActivityVNext.publish.reviewAgain': 'Review again', - 'workflowActivityVNext.publish.reviewAndPublish': 'Review and publish', - 'workflowActivityVNext.publish.reviewDescription': - 'Review what this workflow may do when it runs.', - 'workflowActivityVNext.publish.reviewing': 'Reviewing publication…', - 'workflowActivityVNext.publish.reviewingDescription': - 'Preparing this workflow for review.', - 'workflowActivityVNext.publish.reviewUnavailable': - "We couldn't prepare this workflow for publishing.", 'workflowActivityVNext.publish.readinessIssues': 'Publish readiness issues', 'workflowActivityVNext.publish.resolvePublication': 'Resolve the current publication status before publishing again.', - 'workflowActivityVNext.publish.risk': 'Impact', - 'workflowActivityVNext.publish.risk.destructive': - 'May delete or permanently change data', - 'workflowActivityVNext.publish.risk.readOnly': 'Read only', - 'workflowActivityVNext.publish.risk.write': 'Can make changes', 'workflowActivityVNext.publish.saveBeforePublishing': 'Save this workflow before publishing.', 'workflowActivityVNext.publish.saveChangesBeforePublishing': 'Save your changes before publishing.', - 'workflowActivityVNext.publish.selectService': 'Choose a service', - 'workflowActivityVNext.publish.service': 'Service', - 'workflowActivityVNext.publish.servicesUnavailable': - 'Services are unavailable', 'workflowActivityVNext.publish.submitting': 'Submitting publication…', 'workflowActivityVNext.publish.submittingDescription': - 'Sending this workflow to the selected service.', - 'workflowActivityVNext.publish.title': 'Publish workflow', + 'Publishing this workflow.', 'workflowActivityVNext.publish.waitForPublication': 'Wait for the current publication to finish.', 'workflowActivityVNext.publish.waitForEditorUpdate': 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 50c11cc1e8..57964c4a46 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts @@ -112,68 +112,38 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.editor.waitForPublishedRun': '请等待已发布版本可用。', 'workflowActivityVNext.publish.accepted': '发布请求已接受', - 'workflowActivityVNext.publish.approvalNotRequired': '此请求无需额外审批。', - 'workflowActivityVNext.publish.approvalRequired': '此请求运行前需要审批。', 'workflowActivityVNext.publish.addExecutableStep': '发布前请至少添加一个可执行步骤。', 'workflowActivityVNext.publish.applyNodeChanges': '发布前请应用或放弃节点配置更改。', - 'workflowActivityVNext.publish.backToService': '返回', 'workflowActivityVNext.publish.blocked': '发布受阻 · {count} 个问题', 'workflowActivityVNext.publish.blockedOne': '发布受阻 · 1 个问题', 'workflowActivityVNext.publish.checkAgain': '再次检查', 'workflowActivityVNext.publish.delayed': '发布状态显示所需时间较长', 'workflowActivityVNext.publish.delayedDescription': '请再次检查最新发布状态。', - 'workflowActivityVNext.publish.destinationDescription': - '选择要使用 {workflowName} 的服务。', 'workflowActivityVNext.publish.failed': '无法确认发布状态', 'workflowActivityVNext.publish.failedDescription': - '请检查工作流或再次尝试发布。', + '请处理错误或再次尝试发布。', 'workflowActivityVNext.publish.forbiddenDescription': '你无权查看此发布状态。', - 'workflowActivityVNext.publish.loadingServices': '正在加载服务…', - 'workflowActivityVNext.publish.noExternalRequests': - '没有需要审核的外部请求。', - 'workflowActivityVNext.publish.noServices': '此工作区暂无可用服务', 'workflowActivityVNext.publish.observed': '工作流已发布', - 'workflowActivityVNext.publish.observedDescription': - '所选服务现在正在使用此工作流。', + 'workflowActivityVNext.publish.observedDescription': '工作流现已发布。', 'workflowActivityVNext.publish.observingDescription': - '正在检查所选服务是否已准备就绪。', + '正在检查已发布版本是否就绪。', 'workflowActivityVNext.publish.published': '已发布', 'workflowActivityVNext.publish.publishedServiceId': '已发布服务 ID', 'workflowActivityVNext.publish.publishing': '正在发布', - 'workflowActivityVNext.publish.publishingTo': '正在发布到 {service}', 'workflowActivityVNext.publish.revisionId': '版本 ID', - 'workflowActivityVNext.publish.reviewAgain': '再次审核', - 'workflowActivityVNext.publish.reviewAndPublish': '审核并发布', - 'workflowActivityVNext.publish.reviewDescription': - '查看此工作流运行时可能执行的操作。', - 'workflowActivityVNext.publish.reviewing': '正在准备审核…', - 'workflowActivityVNext.publish.reviewingDescription': - '正在准备此工作流以供审核。', - 'workflowActivityVNext.publish.reviewUnavailable': - '暂时无法准备此工作流以供发布。', 'workflowActivityVNext.publish.readinessIssues': '发布就绪问题', 'workflowActivityVNext.publish.resolvePublication': '再次发布前请先处理当前发布状态。', - 'workflowActivityVNext.publish.risk': '影响', - 'workflowActivityVNext.publish.risk.destructive': - '可能删除数据或造成永久性更改', - 'workflowActivityVNext.publish.risk.readOnly': '只读', - 'workflowActivityVNext.publish.risk.write': '可进行更改', 'workflowActivityVNext.publish.saveBeforePublishing': '请先保存此工作流,再发布。', 'workflowActivityVNext.publish.saveChangesBeforePublishing': '请先保存更改,再发布。', - 'workflowActivityVNext.publish.selectService': '选择服务', - 'workflowActivityVNext.publish.service': '服务', - 'workflowActivityVNext.publish.servicesUnavailable': '服务暂不可用', 'workflowActivityVNext.publish.submitting': '正在提交发布…', - 'workflowActivityVNext.publish.submittingDescription': - '正在将此工作流发送到所选服务。', - 'workflowActivityVNext.publish.title': '发布工作流', + 'workflowActivityVNext.publish.submittingDescription': '正在发布此工作流。', 'workflowActivityVNext.publish.waitForPublication': '请等待当前发布流程完成。', 'workflowActivityVNext.publish.waitForEditorUpdate': 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 index c7bff6fd10..d3156c7608 100644 --- 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 @@ -2622,92 +2622,7 @@ describe('Workflow Activity vNext editor', () => { ).not.toBeInTheDocument(); }); - it('requires an explicitly selected real scope service before publishing a saved workflow', async () => { - mockLocation = - '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-draft-alpha'; - mockStudioApi.getWorkflow.mockResolvedValue({ - workflowId: 'wf-draft-alpha', - name: 'Workflow alpha', - fileName: 'workflow-alpha.yaml', - filePath: '/workflows/workflow-alpha.yaml', - directoryId: 'directory-alpha', - directoryLabel: 'Workflows', - yaml: 'name: workflow_alpha\nroles: []\nsteps:\n - id: step-alpha\n type: llm_call\n', - updatedAtUtc: '2026-08-06T10:00:00Z', - document: { - name: 'workflow_alpha', - roles: [], - steps: [{ id: 'step-alpha', type: 'llm_call' }], - }, - draftExists: true, - findings: [], - }); - mockStudioApi.parseYaml.mockResolvedValue({ - document: { - name: 'workflow_alpha', - roles: [], - steps: [{ id: 'step-alpha', type: 'llm_call' }], - }, - findings: [], - }); - mockStudioApi.serializeYaml.mockResolvedValue({ - yaml: 'name: workflow_alpha\nroles: []\nsteps:\n - id: step-alpha\n type: llm_call\n', - document: { - name: 'workflow_alpha', - roles: [], - steps: [{ id: 'step-alpha', type: 'llm_call' }], - }, - findings: [], - }); - mockScopeRuntimeApi.listServices.mockResolvedValue([ - { - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - tenantId: 'tenant-alpha', - appId: 'app-alpha', - namespace: 'scope-alpha', - serviceId: 'svc-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: 'rev-existing', - activeServingRevisionId: 'rev-existing', - deploymentId: 'deployment-existing', - primaryActorId: 'actor-existing', - deploymentStatus: 'active', - endpoints: [], - policyIds: [], - updatedAt: '2026-08-06T10:00:00Z', - }, - ]); - - renderWithQueryClient(); - - const publish = await screen.findByRole('button', { name: 'Publish' }); - expect(publish).toBeEnabled(); - fireEvent.click(publish); - - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - expect(mockScopeRuntimeApi.listServices).toHaveBeenCalledWith( - 'scope-alpha', - { - take: 200, - }, - ); - const continueButton = within(dialog).getByRole('button', { - name: 'Review and publish', - }); - expect(continueButton).toBeDisabled(); - - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - - expect(continueButton).toBeEnabled(); - }); - - it('submits a saved draft only to the explicitly selected real scope service', async () => { + it('publishes a saved workflow in one click and waits for observed evidence before showing Published', async () => { mockLocation = '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-draft-alpha'; mockCreateWorkflowRevisionIdentityCandidate.mockReturnValue( @@ -2785,7 +2700,7 @@ describe('Workflow Activity vNext editor', () => { acceptanceStage: 'accepted', propagationStage: 'readmodel_propagating', }); - mockScopesApi.getWorkflowDetail.mockResolvedValue({ + const observedWorkflow = { available: true, scopeId: 'scope-alpha', workflow: { @@ -2801,7 +2716,16 @@ describe('Workflow Activity vNext editor', () => { updatedAt: '2026-08-06T10:00:00Z', }, source: null, - }); + }; + let resolveWorkflowObservation: + | ((workflow: typeof observedWorkflow) => void) + | undefined; + mockScopesApi.getWorkflowDetail.mockImplementation( + () => + new Promise((resolve) => { + resolveWorkflowObservation = resolve; + }), + ); mockScopeRuntimeApi.getServiceRevisions.mockResolvedValue({ scopeId: 'scope-alpha', serviceId: 'svc-alpha', @@ -2848,17 +2772,9 @@ describe('Workflow Activity vNext editor', () => { renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); + expect( + screen.queryByRole('dialog', { name: 'Publish workflow' }), + ).not.toBeInTheDocument(); await waitFor(() => expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledWith( @@ -2871,23 +2787,31 @@ describe('Workflow Activity vNext editor', () => { }), ), ); - expect(mockStudioApi.saveAndBindWorkflow).not.toHaveBeenCalled(); - - fireEvent.click( - await within(dialog).findByRole('button', { name: 'Publish' }), - ); await waitFor(() => expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledWith( expect.objectContaining({ revisionId: 'rev-preview-alpha', scopeId: 'scope-alpha', - serviceId: 'svc-alpha', workflowId: 'wf-draft-alpha', workflowYaml: 'name: workflow_alpha\nroles: []\nsteps:\n - id: step-alpha\n type: llm_call\n', }), ), ); + expect(mockScopeRuntimeApi.listServices).not.toHaveBeenCalled(); + expect( + mockStudioApi.saveAndBindWorkflow.mock.calls[0]?.[0], + ).not.toHaveProperty('serviceId'); + expect(screen.getByRole('button', { name: 'Publishing' })).toHaveAttribute( + 'aria-disabled', + 'true', + ); + expect( + screen.queryByRole('button', { name: 'Published' }), + ).not.toBeInTheDocument(); + + resolveWorkflowObservation?.(observedWorkflow); + expect(await screen.findByText('Workflow published')).toBeInTheDocument(); expect(screen.getByText('Workflow ID')).toBeInTheDocument(); expect(screen.getByText('wf-draft-alpha')).toBeInTheDocument(); @@ -3005,27 +2929,11 @@ describe('Workflow Activity vNext editor', () => { renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); - fireEvent.click( - await within(dialog).findByRole('button', { name: 'Publish' }), - ); expect( await screen.findByText("Publication couldn't be confirmed"), ).toBeInTheDocument(); - expect( - screen.getByRole('dialog', { name: 'Publish workflow' }), - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled(); expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledTimes(1); expect(mockScopesApi.getWorkflowDetail).not.toHaveBeenCalled(); expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); @@ -3176,19 +3084,6 @@ describe('Workflow Activity vNext editor', () => { async function publishObservedWorkflow(): Promise { fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - fireEvent.mouseDown( - await within(dialog).findByRole('combobox', { name: 'Service' }), - ); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); - fireEvent.click( - await within(dialog).findByRole('button', { name: 'Publish' }), - ); await screen.findByText('Workflow published'); } @@ -3338,20 +3233,6 @@ describe('Workflow Activity vNext editor', () => { renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); - fireEvent.click( - await within(dialog).findByRole('button', { name: 'Publish' }), - ); expect( await screen.findByText("Publication couldn't be confirmed"), @@ -3389,20 +3270,6 @@ describe('Workflow Activity vNext editor', () => { renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); - fireEvent.click( - await within(dialog).findByRole('button', { name: 'Publish' }), - ); expect(await screen.findByText(message)).toBeInTheDocument(); expect( @@ -3426,7 +3293,7 @@ describe('Workflow Activity vNext editor', () => { expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledTimes(1); }); - it('requires a fresh review before republishing a failed publication receipt', async () => { + it('creates a fresh revision before republishing a failed publication receipt', async () => { arrangeSavedDraftPublication(); const receiptRevisionId = 'rev-receipt-alpha'; const freshRevisionId = 'rev-fresh-beta'; @@ -3542,55 +3409,16 @@ describe('Workflow Activity vNext editor', () => { renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const initialDialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const initialServiceSelect = await within(initialDialog).findByRole( - 'combobox', - { name: 'Service' }, - ); - fireEvent.mouseDown(initialServiceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(initialDialog).getByRole('button', { - name: 'Review and publish', - }), - ); - fireEvent.click( - await within(initialDialog).findByRole('button', { name: 'Publish' }), - ); expect( await screen.findByText("Publication couldn't be confirmed"), ).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Review again' })); - - const freshDialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const freshReview = within(freshDialog).getByRole('button', { - name: 'Review and publish', - }); - expect(freshReview).toBeDisabled(); expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledTimes(1); - - const freshServiceSelect = await within(freshDialog).findByRole( - 'combobox', - { name: 'Service' }, - ); - fireEvent.mouseDown(freshServiceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - expect(freshReview).toBeEnabled(); - fireEvent.click(freshReview); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); await waitFor(() => expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(2), ); - expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledTimes(1); - fireEvent.click( - await within(freshDialog).findByRole('button', { name: 'Publish' }), - ); - await waitFor(() => expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenLastCalledWith( expect.objectContaining({ revisionId: freshRevisionId }), @@ -3623,25 +3451,11 @@ describe('Workflow Activity vNext editor', () => { renderWithQueryClient(); - fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); - const finalPublish = await within(dialog).findByRole('button', { - name: 'Publish', - }); + const publish = await screen.findByRole('button', { name: 'Publish' }); jest.useFakeTimers(); try { - fireEvent.click(finalPublish); + fireEvent.click(publish); await act(async () => { await jest.advanceTimersByTimeAsync(0); }); @@ -3676,230 +3490,39 @@ describe('Workflow Activity vNext editor', () => { } }); - it('retains the latest reviewed publication when an earlier review resolves late', async () => { + it('submits only one publication from rapid repeated clicks', async () => { arrangeSavedDraftPublication(); - const workflowYamlA = - 'name: workflow_alpha\nroles: []\nsteps:\n - id: step-a\n type: llm_call\n'; - const workflowYamlB = - 'name: workflow_alpha\nroles: []\nsteps:\n - id: step-b\n type: llm_call\n'; - const previewA = { - workflowId: 'wf-draft-alpha', - revisionId: 'rev-A', - items: [ - { - callSiteId: 'call-A', - requestContractDigest: 'digest-A', - userServiceId: 'external-A', - method: 'get', - pathTemplate: '/requests/a', - bodyMode: 'none', - bodyRequired: false, - responseMode: 'text', - effectiveRisk: 'read_only', - approvalRequired: false, - allowedExecutionModes: ['interactive'], - }, - ], - }; - const previewB = { - workflowId: 'wf-draft-alpha', - revisionId: 'rev-B', - items: [ - { - callSiteId: 'call-B', - requestContractDigest: 'digest-B', - userServiceId: 'external-B', - method: 'post', - pathTemplate: '/requests/b', - bodyMode: 'json', - bodyRequired: true, - responseMode: 'text', - effectiveRisk: 'write', - approvalRequired: true, - allowedExecutionModes: ['interactive'], - }, - ], - }; - let resolvePreviewA: (preview: unknown) => void = () => undefined; - let resolvePreviewB: (preview: unknown) => void = () => undefined; - const previewAPromise = new Promise((resolve) => { - resolvePreviewA = resolve; - }); - const previewBPromise = new Promise((resolve) => { - resolvePreviewB = resolve; - }); - mockCreateWorkflowRevisionIdentityCandidate - .mockReturnValueOnce('rev-A') - .mockReturnValueOnce('rev-B'); - mockStudioApi.serializeYaml - .mockResolvedValueOnce({ - yaml: workflowYamlA, - document: { - name: 'workflow_alpha', - roles: [], - steps: [{ id: 'step-a', type: 'llm_call' }], - }, - findings: [], - }) - .mockResolvedValueOnce({ - yaml: workflowYamlB, - document: { - name: 'workflow_alpha', - roles: [], - steps: [{ id: 'step-b', type: 'llm_call' }], - }, - findings: [], - }); - mockStudioApi.previewExplicitRequests - .mockImplementationOnce(() => previewAPromise) - .mockImplementationOnce(() => previewBPromise); - mockStudioApi.saveAndBindWorkflow.mockResolvedValue({ - scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - revisionId: 'rev-B', - binding: { - scopeId: 'scope-alpha', - serviceId: 'svc-alpha', - displayName: 'Workflow alpha', - revisionId: 'rev-B', - targetKind: 'workflow', - targetName: 'Workflow alpha', - }, - acceptanceStage: 'accepted', - propagationStage: 'readmodel_propagating', - }); - mockScopesApi.getWorkflowDetail.mockResolvedValue({ - available: true, - scopeId: 'scope-alpha', - workflow: { - scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - displayName: 'Workflow alpha', - serviceKey: 'workflow-alpha', - workflowName: 'Workflow alpha', - actorId: 'actor-workflow-alpha', - activeRevisionId: 'workflow-revision-B', - deploymentId: 'deployment-workflow-alpha', - deploymentStatus: 'Available', - updatedAt: '2026-08-06T10:00:00Z', - }, - source: null, - }); - mockScopeRuntimeApi.getServiceRevisions.mockResolvedValue({ - scopeId: 'scope-alpha', - serviceId: 'svc-alpha', - serviceKey: 'service-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: 'rev-B', - activeServingRevisionId: 'rev-B', - deploymentId: 'deployment-service-alpha', - deploymentStatus: 'Active', - primaryActorId: 'actor-service-alpha', - catalogStateVersion: 12, - catalogLastEventId: 'evt-service-alpha', - updatedAt: '2026-08-06T10:00:00Z', - revisions: [ - { - revisionId: 'rev-B', - implementationKind: 'workflow', - status: 'Published', - artifactHash: 'artifact-publication-B', - failureReason: '', - isDefaultServing: false, - isActiveServing: true, - isServingTarget: true, - allocationWeight: 100, - servingState: 'Active', - deploymentId: 'deployment-service-alpha', - primaryActorId: 'actor-service-alpha', - createdAt: '2026-08-06T10:00:00Z', - preparedAt: '2026-08-06T10:00:01Z', - publishedAt: '2026-08-06T10:00:02Z', - retiredAt: null, - workflowName: 'Workflow alpha', - workflowDefinitionActorId: 'actor-workflow-alpha', - inlineWorkflowCount: 0, - scriptId: '', - scriptRevision: '', - scriptDefinitionActorId: '', - scriptSourceHash: '', - staticActorTypeName: '', - }, - ], - }); - - renderWithQueryClient(); - - fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText('Service alpha')); - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); - await waitFor(() => - expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(1), + let resolvePreview: (preview: unknown) => void = () => undefined; + mockStudioApi.previewExplicitRequests.mockImplementation( + () => + new Promise((resolve) => { + resolvePreview = resolve; + }), ); - fireEvent.click(within(dialog).getByRole('button', { name: 'Back' })); - fireEvent.click( - await within(dialog).findByRole('button', { name: 'Review and publish' }), - ); - await waitFor(() => - expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(2), - ); - expect(mockStudioApi.previewExplicitRequests.mock.calls[0][0]).toEqual( - expect.objectContaining({ - revisionId: 'rev-A', - workflowYaml: workflowYamlA, - }), - ); - expect(mockStudioApi.previewExplicitRequests.mock.calls[1][0]).toEqual( - expect.objectContaining({ - revisionId: 'rev-B', - workflowYaml: workflowYamlB, - }), - ); + renderWithQueryClient(); + const publish = await screen.findByRole('button', { name: 'Publish' }); await act(async () => { - resolvePreviewB(previewB); + publish.click(); + publish.click(); await Promise.resolve(); }); - expect( - await within(dialog).findByText('POST /requests/b'), - ).toBeInTheDocument(); + + expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(1); await act(async () => { - resolvePreviewA(previewA); + resolvePreview({ + workflowId: 'wf-draft-alpha', + revisionId: 'rev-preview-alpha', + items: [], + }); await Promise.resolve(); }); - expect(within(dialog).getByText('POST /requests/b')).toBeInTheDocument(); - fireEvent.click(within(dialog).getByRole('button', { name: 'Publish' })); await waitFor(() => - expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - explicitRequestConfirmations: [ - { - workflowId: 'wf-draft-alpha', - revisionId: 'rev-B', - callSiteId: 'call-B', - requestContractDigest: 'digest-B', - attestedRisk: 'write', - }, - ], - revisionId: 'rev-B', - workflowYaml: workflowYamlB, - }), - ), + expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledTimes(1), ); - expect(mockStudioApi.saveAndBindWorkflow).toHaveBeenCalledTimes(1); - expect(await screen.findByText('Workflow published')).toBeInTheDocument(); }); it('creates on first save for committed-only source and adopts the API-returned draft id', async () => { diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx index b031fcf220..006bd511ce 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx @@ -16,7 +16,6 @@ import { t } from '@/shared/i18n/messages'; import { getLocationSnapshot, history } from '@/shared/navigation/history'; import { studioApi } from '@/shared/studio/api'; import { createWorkflowRevisionIdentityCandidate } from '@/shared/studio/explicitRequestConfirmation'; -import type { StudioExplicitRequestPreview } from '@/shared/studio/models'; import { useConsoleToast } from '@/shared/ui/ConsoleToast'; import { getRunStatusPresentation, @@ -39,9 +38,6 @@ import WorkflowActivityVNextShell from '../WorkflowActivityVNextShell'; import WorkflowNodeInspector, { type WorkflowNodeInspectorHandle, } from './WorkflowNodeInspector'; -import WorkflowPublishDialog, { - type WorkflowPublishConfirmationInput, -} from './WorkflowPublishDialog'; import WorkflowPublishedRunDrawer from './WorkflowPublishedRunDrawer'; function errorMessage(error: unknown): string { @@ -60,21 +56,7 @@ type ActiveEditorRoute = { readonly workflowId: string; }; -type PublicationStage = - | 'idle' - | 'reviewing' - | 'submitting' - | 'accepted' - | 'failed'; - -type PendingWorkflowPublication = { - readonly documentVersion: number; - readonly preview: StudioExplicitRequestPreview; - readonly serviceId: string; - readonly workflowId: string; - readonly workflowName: string; - readonly workflowYaml: string; -}; +type PublicationStage = 'idle' | 'submitting' | 'accepted' | 'failed'; type PublishReadinessIssue = { readonly id: string; @@ -86,31 +68,6 @@ function hasNonBlankIdentifier(value: unknown): value is string { return typeof value === 'string' && Boolean(value.trim()); } -function confirmationsMatchPreview( - input: WorkflowPublishConfirmationInput, - review: PendingWorkflowPublication, -): boolean { - if ( - input.serviceId !== review.serviceId || - input.preview.workflowId !== review.preview.workflowId || - input.preview.revisionId !== review.preview.revisionId || - input.confirmations.length !== review.preview.items.length - ) { - return false; - } - - return review.preview.items.every((item) => - input.confirmations.some( - (confirmation) => - confirmation.workflowId === review.preview.workflowId && - confirmation.revisionId === review.preview.revisionId && - confirmation.callSiteId === item.callSiteId && - confirmation.requestContractDigest === item.requestContractDigest && - confirmation.attestedRisk === item.effectiveRisk, - ), - ); -} - const WorkflowEditorPage: React.FC<{ readonly scopeId: string; readonly workflowId: string; @@ -130,7 +87,6 @@ const WorkflowEditorPage: React.FC<{ const toast = useConsoleToast(); const [mode, setMode] = React.useState<'canvas' | 'yaml'>('canvas'); const [nodeLibraryOpen, setNodeLibraryOpen] = React.useState(false); - const [publishDialogOpen, setPublishDialogOpen] = React.useState(false); const [publicationStage, setPublicationStage] = React.useState('idle'); const [publicationError, setPublicationError] = React.useState(null); @@ -138,8 +94,6 @@ const WorkflowEditorPage: React.FC<{ React.useState(null); const [publishedDocumentVersion, setPublishedDocumentVersion] = React.useState(null); - const [pendingPublication, setPendingPublication] = - React.useState(null); const [runPanelOpen, setRunPanelOpen] = React.useState(false); const [pendingNavigation, setPendingNavigation] = React.useState(null); @@ -148,7 +102,8 @@ const WorkflowEditorPage: React.FC<{ const workflowNameRef = React.useRef>(null); const saveStatusRef = React.useRef(null); const inspectorRef = React.useRef(null); - const publicationReviewGenerationRef = React.useRef(0); + const publicationGenerationRef = React.useRef(0); + const publicationInFlightRef = React.useRef(false); const runRequested = new URLSearchParams(location.search).get('run') === '1'; const editorWriteLocked = editor.saving || editor.structuralMutationPending; const publication = useWorkflowPublication(publicationReceipt); @@ -170,19 +125,18 @@ const WorkflowEditorPage: React.FC<{ !hasUnappliedNodeChanges, ); - const invalidatePublicationReview = React.useCallback(() => { - publicationReviewGenerationRef.current += 1; + const invalidatePublication = React.useCallback(() => { + publicationGenerationRef.current += 1; + publicationInFlightRef.current = false; }, []); const clearPublication = React.useCallback(() => { - invalidatePublicationReview(); - setPendingPublication(null); + invalidatePublication(); setPublicationError(null); setPublicationReceipt(null); setPublishedDocumentVersion(null); setPublicationStage('idle'); - setPublishDialogOpen(false); - }, [invalidatePublicationReview]); + }, [invalidatePublication]); React.useEffect(() => { if (runRequested && canOpenPublishedRun) setRunPanelOpen(true); @@ -356,165 +310,114 @@ const WorkflowEditorPage: React.FC<{ await editor.retryMaterialization(); }, [editor.retryMaterialization]); - const reviewPublication = React.useCallback( - async ( - selectedServiceId: string, - ): Promise => { - if (!hasNonBlankIdentifier(selectedServiceId)) { - throw new Error('Select a service before reviewing this publication.'); - } + const publishWorkflow = React.useCallback(async (): Promise => { + if (publicationInFlightRef.current) return; - const reviewGeneration = ++publicationReviewGenerationRef.current; - const isCurrentReview = () => - reviewGeneration === publicationReviewGenerationRef.current; - setPublicationError(null); - setPublicationReceipt(null); - setPublicationStage('reviewing'); - try { - const preparation = await editor.preparePublication(); - if (!isCurrentReview()) { - throw new Error('This publication review was superseded.'); - } - const revisionId = createWorkflowRevisionIdentityCandidate(); - if (!hasNonBlankIdentifier(revisionId)) { - throw new Error('A publication revision could not be prepared.'); - } + publicationInFlightRef.current = true; + const publicationGeneration = ++publicationGenerationRef.current; + const isCurrentPublication = () => + publicationGeneration === publicationGenerationRef.current; + setPublicationError(null); + setPublicationReceipt(null); + setPublishedDocumentVersion(null); + setPublicationStage('submitting'); - const preview = await studioApi.previewExplicitRequests({ - executionMode: 'interactive', - revisionId, - scopeId: activeScopeId, - workflowId: preparation.workflowId, - workflowYaml: preparation.workflowYaml, - }); - if (!isCurrentReview()) return preview; - if ( - preview.workflowId !== preparation.workflowId || - preview.revisionId !== revisionId - ) { - throw new Error( - 'The publication review does not match the saved workflow.', - ); - } - if ( - preview.items.some( - (item) => !item.allowedExecutionModes.includes('interactive'), - ) - ) { - throw new Error( - 'An external request is unavailable for interactive publication.', - ); - } + try { + const preparation = await editor.preparePublication(); + if (!isCurrentPublication()) return; - if (!isCurrentReview()) return preview; - setPendingPublication({ - documentVersion: preparation.documentVersion, - preview, - serviceId: selectedServiceId, - workflowId: preparation.workflowId, - workflowName: preparation.workflowName, - workflowYaml: preparation.workflowYaml, - }); - setPublicationStage('idle'); - return preview; - } catch (error) { - if (!isCurrentReview()) throw error; - setPendingPublication(null); - setPublicationError(error); - setPublicationStage('failed'); - throw error; + const revisionId = createWorkflowRevisionIdentityCandidate(); + if (!hasNonBlankIdentifier(revisionId)) { + throw new Error('A publication revision could not be prepared.'); } - }, - [activeScopeId, editor.preparePublication], - ); - const publishReviewedWorkflow = React.useCallback( - async (input: WorkflowPublishConfirmationInput): Promise => { - const review = pendingPublication; + const preview = await studioApi.previewExplicitRequests({ + executionMode: 'interactive', + revisionId, + scopeId: activeScopeId, + workflowId: preparation.workflowId, + workflowYaml: preparation.workflowYaml, + }); + if (!isCurrentPublication()) return; if ( - !review || - editor.documentVersion !== review.documentVersion || - !confirmationsMatchPreview(input, review) + editor.documentVersion !== preparation.documentVersion || + preview.workflowId !== preparation.workflowId || + preview.revisionId !== revisionId ) { - const error = new Error( - 'The publication confirmation does not match the reviewed workflow.', + throw new Error( + 'The publication preparation does not match the saved workflow.', + ); + } + if ( + preview.items.some( + (item) => !item.allowedExecutionModes.includes('interactive'), + ) + ) { + throw new Error( + 'An external request is unavailable for interactive publication.', ); - setPublicationError(error); - setPublicationStage('failed'); - throw error; } - setPublicationError(null); - setPublicationStage('submitting'); - try { - const result = await studioApi.saveAndBindWorkflow({ - displayName: review.workflowName, - explicitRequestConfirmations: input.confirmations, - revisionId: review.preview.revisionId, - scopeId: activeScopeId, - serviceId: review.serviceId, - workflowId: review.workflowId, - workflowName: review.workflowName, - workflowYaml: review.workflowYaml, - }); - const binding = result.binding; - if ( - result.acceptanceStage !== 'accepted' || - result.scopeId !== activeScopeId || - !hasNonBlankIdentifier(result.workflowId) || - result.workflowId !== review.workflowId || - !hasNonBlankIdentifier(result.revisionId) || - result.revisionId !== review.preview.revisionId || - (result.workflow && - (result.workflow.scopeId !== result.scopeId || - result.workflow.workflowId !== result.workflowId || - result.workflow.revisionId !== result.revisionId)) || - !binding || - binding.scopeId !== activeScopeId || - !hasNonBlankIdentifier(binding.serviceId) || - binding.serviceId !== review.serviceId || - binding.targetKind !== 'workflow' || - binding.revisionId !== result.revisionId - ) { - throw new Error( - 'The accepted publication response does not match the reviewed service.', - ); - } + const explicitRequestConfirmations = preview.items.map((item) => ({ + workflowId: preview.workflowId, + revisionId: preview.revisionId, + callSiteId: item.callSiteId, + requestContractDigest: item.requestContractDigest, + attestedRisk: item.effectiveRisk, + })); + const result = await studioApi.saveAndBindWorkflow({ + appId: 'studio', + displayName: preparation.workflowName, + explicitRequestConfirmations, + exposureDesired: true, + inlineWorkflowYamls: {}, + revisionId: preview.revisionId, + scopeId: activeScopeId, + workflowId: preparation.workflowId, + workflowName: preparation.workflowName, + workflowYaml: preparation.workflowYaml, + }); + if (!isCurrentPublication()) return; - setPublicationReceipt({ - publishedServiceId: binding.serviceId, - scopeId: result.scopeId, - revisionId: result.revisionId, - workflowId: result.workflowId, - }); - setPublishedDocumentVersion(review.documentVersion); - setPendingPublication(null); - setPublicationStage('accepted'); - setPublishDialogOpen(false); - } catch (error) { - setPublicationError(error); - setPublicationStage('failed'); - throw error; + const binding = result.binding; + if ( + result.acceptanceStage !== 'accepted' || + result.scopeId !== activeScopeId || + !hasNonBlankIdentifier(result.workflowId) || + result.workflowId !== preparation.workflowId || + !hasNonBlankIdentifier(result.revisionId) || + result.revisionId !== preview.revisionId || + (result.workflow && + (result.workflow.scopeId !== result.scopeId || + result.workflow.workflowId !== result.workflowId || + result.workflow.revisionId !== result.revisionId)) || + !binding || + binding.scopeId !== activeScopeId || + !hasNonBlankIdentifier(binding.serviceId) || + binding.targetKind !== 'workflow' || + binding.revisionId !== result.revisionId + ) { + throw new Error( + 'The accepted publication response does not match the submitted workflow.', + ); } - }, - [activeScopeId, editor.documentVersion, pendingPublication], - ); - const returnPublicationToSelection = React.useCallback(() => { - invalidatePublicationReview(); - setPendingPublication(null); - setPublicationError(null); - setPublicationStage('idle'); - }, [invalidatePublicationReview]); - - const restartPublicationReview = React.useCallback(() => { - invalidatePublicationReview(); - setPendingPublication(null); - setPublicationError(null); - setPublicationReceipt(null); - setPublicationStage('idle'); - setPublishDialogOpen(true); - }, [invalidatePublicationReview]); + setPublicationReceipt({ + publishedServiceId: binding.serviceId, + scopeId: result.scopeId, + revisionId: result.revisionId, + workflowId: result.workflowId, + }); + setPublishedDocumentVersion(preparation.documentVersion); + setPublicationStage('accepted'); + } catch (error) { + if (!isCurrentPublication()) return; + setPublicationError(error); + setPublicationStage('failed'); + } finally { + if (isCurrentPublication()) publicationInFlightRef.current = false; + } + }, [activeScopeId, editor.documentVersion, editor.preparePublication]); const saveAndLeave = async () => { if (await saveWorkflow()) { @@ -646,15 +549,15 @@ const WorkflowEditorPage: React.FC<{ const publicationObservationPending = publicationReceipt !== null && publication.phase !== 'observed'; const publicationActionPending = - publicationStage === 'reviewing' || - publicationStage === 'submitting' || - publication.phase === 'observing'; + publicationStage === 'submitting' || publication.phase === 'observing'; const canRetryPublicationObservation = publicationReceipt !== null && (publication.phase === 'delayed' || publication.phase === 'failed' || publication.phase === 'unauthorized' || publication.phase === 'forbidden'); + const canRetryPublicationSubmission = + publicationReceipt === null && publicationStage === 'failed'; const revealPublicationStatus = () => { document .getElementById('workflow-publication-status') @@ -841,7 +744,7 @@ const WorkflowEditorPage: React.FC<{ aria-disabled={!canPublish} icon={} onClick={() => { - if (canPublish) setPublishDialogOpen(true); + if (canPublish) void publishWorkflow(); }} > {publishLabel} @@ -1031,64 +934,62 @@ const WorkflowEditorPage: React.FC<{ {publicationPhase !== 'idle' ? ( - - {publication.phase === 'failed' ? ( - ) : null} + {canRetryPublicationSubmission || + publication.phase === 'failed' ? ( + + ) : null} ) : undefined } description={ <> - {publicationPhase === 'reviewing' + {publicationPhase === 'submitting' ? t( - 'workflowActivityVNext.publish.reviewingDescription', - 'Preparing the saved workflow for review.', + 'workflowActivityVNext.publish.submittingDescription', + 'Publishing this workflow.', ) - : publicationPhase === 'submitting' + : publicationPhase === 'accepted' || + publicationPhase === 'observing' ? t( - 'workflowActivityVNext.publish.submittingDescription', - 'Sending this reviewed publication to the selected service.', + 'workflowActivityVNext.publish.observingDescription', + 'Checking whether the published revision is ready.', ) - : publicationPhase === 'accepted' || - publicationPhase === 'observing' + : publicationPhase === 'delayed' ? t( - 'workflowActivityVNext.publish.observingDescription', - 'Checking the workflow and selected service revision.', + 'workflowActivityVNext.publish.delayedDescription', + 'Check again for the latest publication status.', ) - : publicationPhase === 'delayed' + : publicationPhase === 'observed' ? t( - 'workflowActivityVNext.publish.delayedDescription', - 'Check again for the latest publication status.', + 'workflowActivityVNext.publish.observedDescription', + 'The workflow is now published.', ) - : publicationPhase === 'observed' + : publicationPhase === 'unauthorized' ? t( - 'workflowActivityVNext.publish.observedDescription', - 'The selected service is now using this workflow revision.', + 'workflowActivityVNext.publish.unauthorizedDescription', + 'Sign in again to check this publication.', ) - : publicationPhase === 'unauthorized' + : publicationPhase === 'forbidden' ? t( - 'workflowActivityVNext.publish.unauthorizedDescription', - 'Sign in again to check this publication.', + 'workflowActivityVNext.publish.forbiddenDescription', + "You don't have access to check this publication.", ) - : publicationPhase === 'forbidden' - ? t( - 'workflowActivityVNext.publish.forbiddenDescription', - "You don't have access to check this publication.", - ) - : t( - 'workflowActivityVNext.publish.failedDescription', - 'Review the workflow or try publishing again.', - )} + : t( + 'workflowActivityVNext.publish.failedDescription', + 'Resolve the error or try publishing again.', + )} {publicationPhase === 'observed' && publicationReceipt ? (
@@ -1119,46 +1020,41 @@ const WorkflowEditorPage: React.FC<{ } message={ - publicationPhase === 'reviewing' + publicationPhase === 'submitting' ? t( - 'workflowActivityVNext.publish.reviewing', - 'Reviewing publication…', + 'workflowActivityVNext.publish.submitting', + 'Submitting publication…', ) - : publicationPhase === 'submitting' + : publicationPhase === 'accepted' || + publicationPhase === 'observing' ? t( - 'workflowActivityVNext.publish.submitting', - 'Submitting publication…', + 'workflowActivityVNext.publish.accepted', + 'Publication accepted', ) - : publicationPhase === 'accepted' || - publicationPhase === 'observing' + : publicationPhase === 'delayed' ? t( - 'workflowActivityVNext.publish.accepted', - 'Publication accepted', + 'workflowActivityVNext.publish.delayed', + 'Publication is taking longer to appear', ) - : publicationPhase === 'delayed' + : publicationPhase === 'observed' ? t( - 'workflowActivityVNext.publish.delayed', - 'Publication is taking longer to appear', + 'workflowActivityVNext.publish.observed', + 'Workflow published', ) - : publicationPhase === 'observed' + : publicationPhase === 'unauthorized' ? t( - 'workflowActivityVNext.publish.observed', - 'Workflow published', + 'workflowActivityVNext.state.unauthorized', + 'Sign in to continue', ) - : publicationPhase === 'unauthorized' + : publicationPhase === 'forbidden' ? t( - 'workflowActivityVNext.state.unauthorized', - 'Sign in to continue', + 'workflowActivityVNext.state.forbidden', + "You don't have access to this workspace", + ) + : t( + 'workflowActivityVNext.publish.failed', + "Publication couldn't be confirmed", ) - : publicationPhase === 'forbidden' - ? t( - 'workflowActivityVNext.state.forbidden', - "You don't have access to this workspace", - ) - : t( - 'workflowActivityVNext.publish.failed', - "Publication couldn't be confirmed", - ) } id="workflow-publication-status" showIcon @@ -1167,8 +1063,7 @@ const WorkflowEditorPage: React.FC<{ ? 'success' : publicationPhase === 'delayed' ? 'warning' - : publicationPhase === 'reviewing' || - publicationPhase === 'submitting' || + : publicationPhase === 'submitting' || publicationPhase === 'accepted' || publicationPhase === 'observing' ? 'info' @@ -1540,15 +1435,6 @@ const WorkflowEditorPage: React.FC<{ ) : null} - ({ - scopeRuntimeApi: { - listServices: jest.fn(), - }, -})); - -jest.mock('@/shared/ui/ConsoleToast', () => ({ - useConsoleToast: () => mockConsoleToast, -})); - -const mockListServices = scopeRuntimeApi.listServices as jest.Mock; - -const service = { - activeServingRevisionId: 'rev-existing', - appId: 'app-alpha', - defaultServingRevisionId: 'rev-existing', - deploymentId: 'deployment-alpha', - deploymentStatus: 'Active', - displayName: 'Service alpha', - endpoints: [], - namespace: 'scope-alpha', - policyIds: [], - primaryActorId: 'actor-service-alpha', - serviceId: 'svc-alpha', - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - tenantId: 'tenant-alpha', - updatedAt: '2026-08-06T10:00:00Z', -} as const; - -function createPreview(callSiteId: string) { - return { - items: [ - { - allowedExecutionModes: ['interactive'], - approvalRequired: false, - bodyMode: 'json', - bodyRequired: true, - callSiteId, - effectiveRisk: 'write', - method: 'post', - pathTemplate: '/external/notifications', - requestContractDigest: `digest-${callSiteId}`, - responseMode: 'text', - userServiceId: service.serviceId, - }, - ], - revisionId: 'rev-preview-alpha', - workflowId: 'wf-alpha', - } as const; -} - -function createDeferred() { - let resolve: (value: T) => void = () => undefined; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - - return { promise, resolve }; -} - -function renderDialog({ - onPublish, - onReview, - onReturnToSelection, -}: { - readonly onPublish: () => Promise; - readonly onReview: ( - serviceId: string, - ) => Promise>; - readonly onReturnToSelection: () => void; -}) { - return renderWithQueryClient( - undefined} - onPublish={onPublish} - onReview={onReview} - onReturnToSelection={onReturnToSelection} - open - scopeId="scope-alpha" - workflowName="Workflow alpha" - />, - ); -} - -async function selectService(dialog: HTMLElement): Promise { - const serviceSelect = await within(dialog).findByRole('combobox', { - name: 'Service', - }); - fireEvent.mouseDown(serviceSelect); - fireEvent.click(await screen.findByText(service.displayName)); - await waitFor(() => - expect( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ).toBeEnabled(), - ); -} - -function startReview(dialog: HTMLElement): void { - fireEvent.click( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ); -} - -describe('WorkflowPublishDialog', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockListServices.mockResolvedValue([service]); - }); - - it('reports a review failure with a toast instead of an inline error', async () => { - const onReview = jest.fn().mockRejectedValue( - new Error('POST /api/studio/publication-review returned 500'), - ); - - renderDialog({ - onPublish: jest.fn(async () => undefined), - onReview, - onReturnToSelection: jest.fn(), - }); - - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - await selectService(dialog); - startReview(dialog); - - await waitFor(() => - expect(mockConsoleToast.error).toHaveBeenCalledWith( - "We couldn't prepare this workflow for publishing.", - ), - ); - expect( - within(dialog).queryByText( - "We couldn't prepare this workflow for publishing.", - ), - ).not.toBeInTheDocument(); - expect( - within(dialog).queryByText( - 'POST /api/studio/publication-review returned 500', - ), - ).not.toBeInTheDocument(); - expect( - within(dialog).getByRole('button', { name: 'Review and publish' }), - ).toBeEnabled(); - }); - - it('keeps the final publish action unavailable while preparing the review', async () => { - const preview = createDeferred>(); - const onPublish = jest.fn(async () => undefined); - const onReview = jest.fn((_serviceId: string) => preview.promise); - - renderDialog({ - onPublish, - onReview, - onReturnToSelection: jest.fn(), - }); - - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - await selectService(dialog); - startReview(dialog); - - await waitFor(() => expect(onReview).toHaveBeenCalledWith('svc-alpha')); - expect( - await within(dialog).findByText('Reviewing publication…'), - ).toBeInTheDocument(); - expect( - within(dialog).queryByRole('button', { name: 'Publish' }), - ).not.toBeInTheDocument(); - expect(onPublish).not.toHaveBeenCalled(); - - await act(async () => { - preview.resolve(createPreview('request-alpha')); - }); - - expect( - await within(dialog).findByText('POST /external/notifications'), - ).toBeInTheDocument(); - expect( - within(dialog).getByRole('button', { name: 'Publish' }), - ).toBeEnabled(); - }); - - it('ignores a preview that resolves after returning and starting a newer review', async () => { - const firstPreview = createDeferred>(); - const secondPreview = createDeferred>(); - const firstResult = createPreview('request-first'); - const secondResult = createPreview('request-second'); - const onPublish = jest.fn(async () => undefined); - const onReview = jest - .fn>, [string]>() - .mockReturnValueOnce(firstPreview.promise) - .mockReturnValueOnce(secondPreview.promise); - const onReturnToSelection = jest.fn(); - - renderDialog({ onPublish, onReview, onReturnToSelection }); - - const dialog = await screen.findByRole('dialog', { - name: 'Publish workflow', - }); - await selectService(dialog); - startReview(dialog); - await waitFor(() => expect(onReview).toHaveBeenCalledTimes(1)); - - fireEvent.click(within(dialog).getByRole('button', { name: 'Back' })); - expect(onReturnToSelection).toHaveBeenCalledTimes(1); - startReview(dialog); - await waitFor(() => expect(onReview).toHaveBeenCalledTimes(2)); - - await act(async () => { - firstPreview.resolve(firstResult); - }); - - expect( - within(dialog).queryByRole('button', { name: 'Publish' }), - ).not.toBeInTheDocument(); - expect(onPublish).not.toHaveBeenCalled(); - - await act(async () => { - secondPreview.resolve(secondResult); - }); - - fireEvent.click( - await within(dialog).findByRole('button', { name: 'Publish' }), - ); - await waitFor(() => expect(onPublish).toHaveBeenCalledTimes(1)); - expect(onPublish).toHaveBeenCalledWith( - expect.objectContaining({ - preview: secondResult, - serviceId: service.serviceId, - }), - ); - }); -}); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowPublishDialog.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowPublishDialog.tsx deleted file mode 100644 index 8a34427b34..0000000000 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowPublishDialog.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { Alert, Button, Modal, Select, Space, Typography } from 'antd'; -import React from 'react'; -import { scopeRuntimeApi } from '@/shared/api/scopeRuntimeApi'; -import { t } from '@/shared/i18n/messages'; -import { useConsoleToast } from '@/shared/ui/ConsoleToast'; -import type { - StudioExplicitRequestConfirmation, - StudioExplicitRequestPreview, - StudioExplicitRequestRisk, -} from '@/shared/studio/models'; - -export type WorkflowPublishConfirmationInput = { - readonly confirmations: readonly StudioExplicitRequestConfirmation[]; - readonly preview: StudioExplicitRequestPreview; - readonly serviceId: string; -}; - -type WorkflowPublishDialogProps = { - readonly onCancel: () => void; - readonly onPublish: ( - input: WorkflowPublishConfirmationInput, - ) => Promise; - readonly onReview: ( - serviceId: string, - ) => Promise; - readonly onReturnToSelection: () => void; - readonly open: boolean; - readonly scopeId: string; - readonly workflowName: string; -}; - -type PublicationStage = 'selecting' | 'preparing' | 'reviewing' | 'submitting'; - -type PublicationReview = { - readonly preview: StudioExplicitRequestPreview; - readonly serviceId: string; -}; - -function errorStatus(error: unknown): number | undefined { - if ( - error && - typeof error === 'object' && - 'status' in error && - typeof error.status === 'number' - ) { - return error.status; - } - return undefined; -} - -function riskLabel(risk: StudioExplicitRequestRisk): string { - switch (risk) { - case 'destructive': - return t( - 'workflowActivityVNext.publish.risk.destructive', - 'Can make changes', - ); - case 'write': - return t('workflowActivityVNext.publish.risk.write', 'Can make changes'); - default: - return t('workflowActivityVNext.publish.risk.readOnly', 'Read only'); - } -} - -function errorCopy(error: unknown): string { - const status = errorStatus(error); - if (status === 401) { - return t('workflowActivityVNext.state.unauthorized', 'Sign in to continue'); - } - if (status === 403) { - return t( - 'workflowActivityVNext.state.forbidden', - "You don't have access to this workspace", - ); - } - return t( - 'workflowActivityVNext.publish.reviewUnavailable', - "We couldn't prepare this workflow for publishing.", - ); -} - -const WorkflowPublishDialog: React.FC = ({ - onCancel, - onPublish, - onReview, - onReturnToSelection, - open, - scopeId, - workflowName, -}) => { - const normalizedScopeId = scopeId.trim(); - const [selectedServiceId, setSelectedServiceId] = React.useState(''); - const [stage, setStage] = React.useState('selecting'); - const [review, setReview] = React.useState(null); - const toast = useConsoleToast(); - const reviewGenerationRef = React.useRef(0); - const servicesQuery = useQuery({ - enabled: open && Boolean(normalizedScopeId), - queryKey: [ - 'workflow-activity-vnext', - 'publication-services', - normalizedScopeId, - ], - queryFn: () => - scopeRuntimeApi.listServices(normalizedScopeId, { take: 200 }), - retry: false, - }); - const services = servicesQuery.data ?? []; - const selectedService = services.find( - (service) => service.serviceId === selectedServiceId, - ); - const selectedServiceIsAvailable = Boolean(selectedService); - - React.useEffect(() => { - if (open && selectedServiceIsAvailable) return; - reviewGenerationRef.current += 1; - setSelectedServiceId(''); - setStage('selecting'); - setReview(null); - }, [open, selectedServiceIsAvailable]); - - const servicesStatus = errorStatus(servicesQuery.error); - const hasServiceError = Boolean(servicesQuery.error); - const hasNoServices = - !servicesQuery.isPending && !hasServiceError && services.length === 0; - const canReview = - stage === 'selecting' && - Boolean(selectedServiceId) && - selectedServiceIsAvailable && - !hasServiceError && - !hasNoServices; - - const returnToSelection = React.useCallback(() => { - reviewGenerationRef.current += 1; - setStage('selecting'); - setReview(null); - onReturnToSelection(); - }, [onReturnToSelection]); - - const handleReview = React.useCallback(async () => { - if (!canReview) return; - const generation = ++reviewGenerationRef.current; - setReview(null); - setStage('preparing'); - try { - const preview = await onReview(selectedServiceId); - if (generation !== reviewGenerationRef.current) return; - if ( - preview.items.some( - (item) => !item.allowedExecutionModes.includes('interactive'), - ) - ) { - throw new Error( - 'Interactive publication is unavailable for an external request.', - ); - } - setReview({ preview, serviceId: selectedServiceId }); - setStage('reviewing'); - } catch (error) { - if (generation !== reviewGenerationRef.current) return; - toast.error(errorCopy(error)); - setStage('selecting'); - } - }, [canReview, onReview, selectedServiceId, toast]); - - const handlePublish = React.useCallback(async () => { - if (!review || stage !== 'reviewing') return; - setStage('submitting'); - try { - const confirmations = review.preview.items.map((item) => ({ - workflowId: review.preview.workflowId, - revisionId: review.preview.revisionId, - callSiteId: item.callSiteId, - requestContractDigest: item.requestContractDigest, - attestedRisk: item.effectiveRisk, - })); - await onPublish({ - confirmations, - preview: review.preview, - serviceId: review.serviceId, - }); - } catch (error) { - toast.error(errorCopy(error)); - setStage('reviewing'); - } - }, [onPublish, review, stage, toast]); - - const close = React.useCallback(() => { - if (stage === 'submitting') return; - if (stage === 'preparing' || stage === 'reviewing') { - returnToSelection(); - return; - } - reviewGenerationRef.current += 1; - onCancel(); - }, [onCancel, returnToSelection, stage]); - - const serviceErrorAlert = hasServiceError ? ( - void servicesQuery.refetch()}> - {t('workflowActivityVNext.common.retry', 'Retry')} - - ) - } - message={ - servicesStatus === 401 - ? t('workflowActivityVNext.state.unauthorized', 'Sign in to continue') - : servicesStatus === 403 - ? t( - 'workflowActivityVNext.state.forbidden', - "You don't have access to this workspace", - ) - : t( - 'workflowActivityVNext.publish.servicesUnavailable', - 'Services are unavailable', - ) - } - showIcon - type="error" - /> - ) : null; - - const selectionContent = ( - <> - - {t( - 'workflowActivityVNext.publish.destinationDescription', - 'Choose the service that will use {workflowName}.', - { workflowName }, - )} - - {servicesQuery.isPending ? ( - - {t( - 'workflowActivityVNext.publish.loadingServices', - 'Loading services…', - )} - - ) : null} - {serviceErrorAlert} - {hasNoServices ? ( - void servicesQuery.refetch()}> - {t('workflowActivityVNext.common.retry', 'Retry')} - - } - message={t( - 'workflowActivityVNext.publish.noServices', - 'No services are available in this workspace', - )} - showIcon - type="info" - /> - ) : null} - {!servicesQuery.isPending && !hasServiceError && services.length > 0 ? ( - editor.updateTitle(event.target.value)} - ref={workflowNameRef} - value={editor.workflowTitle} - />
**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:** Replace the workflow editor's duplicated heading and name field with one inline-editable heading. + +**Architecture:** Keep the shell's string `title` as the navigation and fallback label, and add an optional `heading` React node for page-specific interactive headings. The workflow editor supplies its existing controlled name input through that slot and removes the duplicate toolbar field; all title state and validation focus continue through the existing editor hook and ref. + +**Tech Stack:** React 19, TypeScript, Ant Design 6, Jest, Testing Library + +--- + +### Task 1: Lock the single-title behavior + +**Files:** +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx` + +- [ ] **Step 1: Write the failing regression test** + +Strengthen the existing `keeps the editor header focused on the workflow name` +test: + +```tsx +it('keeps the editor header focused on one inline workflow name', async () => { + renderWithQueryClient(); + + await screen.findByDisplayValue('Committed source'); + const workflowNameEditors = screen.getAllByRole('textbox', { + name: 'Workflow name', + }); + + expect(workflowNameEditors).toHaveLength(1); + expect(workflowNameEditors[0].closest('h1')).not.toBeNull(); + expect( + screen.queryByText('Build, test, and refine this workflow.'), + ).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +pnpm --dir apps/aevatar-console-web jest --runInBand \ + src/pages/workflow-activity-vnext/index.test.tsx \ + -t "keeps the editor header focused on one inline workflow name" +``` + +Expected: FAIL because the current name input is in the secondary toolbar, outside the page `h1`. + +### Task 2: Move editing into the heading + +**Files:** +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/WorkflowActivityVNextShell.tsx` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/styles.ts` + +- [ ] **Step 1: Add a custom heading slot to the shell** + +Add `readonly heading?: React.ReactNode` to `ShellProps`, destructure it, and render: + +```tsx +

{heading ?? title}

+``` + +- [ ] **Step 2: Supply the controlled title input through the heading slot** + +Pass this prop from `WorkflowEditorPage`: + +```tsx +heading={ + editor.updateTitle(event.target.value)} + ref={workflowNameRef} + value={editor.workflowTitle} + variant="borderless" + /> +} +``` + +Delete the identical `Input` from `wa-vnext__editor-toolbar`. Keep save status and the Canvas/YAML segmented control in that toolbar. + +- [ ] **Step 3: Style the heading as an inline editor** + +Replace toolbar-oriented name sizing with heading-specific rules: + +```css +.wa-vnext__heading-copy--custom { flex: 1 1 auto; max-width: min(560px, 100%); width: 100%; } +.wa-vnext__heading-copy--custom h1 { min-width: 0; width: 100%; } +.wa-vnext__editor-name.ant-input { + color: var(--wa-ink); + font-size: 28px; + font-weight: 700; + height: 36px; + line-height: 28px; + max-width: 100%; + padding: 2px 4px; +} +.wa-vnext__editor-toolbar { justify-content: flex-end; } +``` + +At the mobile breakpoint, set the title input to `22px` and keep the custom heading container at full available width so long underscore-separated names remain visible. Remove obsolete mobile toolbar rules that treated the name input as a separate row. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the same command from Task 1. Expected: PASS with one textbox nested inside `h1`. + +### Task 3: Focused verification and delivery + +**Files:** +- Verify: all files changed in Tasks 1 and 2 + +- [ ] **Step 1: Run the frontend scope analyzer** + +```bash +python3 ~/.codex/skills/frontend-incremental-pr/scripts/frontend_change_scope.py --repo . +``` + +- [ ] **Step 2: Run dependency-related tests and changed-file static checks** + +Run the workflow activity test file plus the analyzer-reported changed-file checks. Do not run the full frontend suite, package typecheck, or production build. + +- [ ] **Step 3: Run the required test stability guard** + +```bash +bash tools/ci/test_stability_guards.sh +``` + +- [ ] **Step 4: Browser-check the exact editor route** + +Use the already running preview on port `5173`. Verify the intended workflow editor route renders, contains one inline title editor, and has no overlap at desktop and mobile viewports. If authentication or backend state blocks the route, stop the preview handoff and report the block rather than claiming visual verification. + +- [ ] **Step 5: Review and deliver** + +Review the complete diff, stage only the plan, test, shell, editor, and styles files, commit with `Use one workflow editor title`, push `fix/2026-08-06_one-click-workflow-publish`, and update PR `#3276` with exact focused commands and the statement that full frontend verification is delegated to GitHub CI. From e9cea28874d5da009129c0d2df4cb7e32d609c29 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:12:00 +0800 Subject: [PATCH 05/15] Use compact publish blocker tooltip --- .../workflow-activity-vnext/index.test.tsx | 34 ++++------- .../pages/workflow-activity-vnext/styles.ts | 4 +- .../workflows/WorkflowEditorPage.tsx | 61 +++---------------- 3 files changed, 23 insertions(+), 76 deletions(-) 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 index 0b2318bfe2..3616f09f89 100644 --- 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 @@ -3081,7 +3081,7 @@ describe('Workflow Activity vNext editor', () => { await publishObservedWorkflow(); } - it('keeps every publish blocker focusable and links validation issues to their fields and steps', async () => { + it('shows publish blockers in the standard compact tooltip', async () => { mockStudioApi.getWorkflow.mockResolvedValue({ workflowId: 'wf-committed-source', name: 'Committed source', @@ -3127,28 +3127,18 @@ describe('Workflow Activity vNext editor', () => { ).not.toBeInTheDocument(); fireEvent.focus(publish); - const checklist = await screen.findByRole('region', { - name: 'Publish readiness issues', - }); - expect(within(checklist).getAllByRole('listitem')).toHaveLength(3); - - fireEvent.click( - within(checklist).getByRole('button', { - name: 'Workflow name is required.', - }), - ); - expect( - screen.getByRole('textbox', { name: 'Workflow name' }), - ).toHaveFocus(); - - fireEvent.click( - within(checklist).getByRole('button', { - name: 'Step instruction is required.', - }), - ); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip.closest('.ant-tooltip')).not.toBeNull(); + expect(tooltip.closest('.ant-popover')).toBeNull(); + expect(within(tooltip).getAllByRole('listitem')).toHaveLength(3); + expect(within(tooltip).queryByRole('region')).not.toBeInTheDocument(); + expect(within(tooltip).queryByRole('button')).not.toBeInTheDocument(); + expect( + within(tooltip).getByText('Workflow name is required.'), + ).toBeInTheDocument(); expect( - await screen.findByRole('complementary', { name: 'Configure step-root' }), - ).toBeVisible(); + within(tooltip).getByText('Step instruction is required.'), + ).toBeInTheDocument(); expect(mockStudioApi.previewExplicitRequests).not.toHaveBeenCalled(); }); 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 25d0ecbf27..ee0c2d6dc0 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 @@ -212,8 +212,8 @@ export const workflowActivityVNextCss = ` } .wa-vnext__editor-toolbar-meta { align-items: center; display: flex; flex: 0 1 auto; gap: 8px; min-width: 0; } .wa-vnext__publish-readiness { max-width: min(360px, calc(100vw - 32px)); } -.wa-vnext__publish-readiness ul { display: grid; gap: 4px; list-style: none; margin: 0; padding: 0; } -.wa-vnext__publish-readiness .ant-btn { height: auto; line-height: 1.35; padding: 4px 0; text-align: left; white-space: normal; } +.wa-vnext__publish-readiness ul { display: grid; gap: 6px; list-style: none; margin: 0; padding: 0; } +.wa-vnext__publish-readiness li { line-height: 1.4; overflow-wrap: anywhere; } .wa-vnext__publication-identities { display: grid; gap: 6px; margin: 12px 0 0; } .wa-vnext__publication-identities > div { display: grid; gap: 2px; grid-template-columns: minmax(116px, max-content) minmax(0, 1fr); } .wa-vnext__publication-identities dt { color: var(--wa-muted); font-size: 11px; font-weight: 700; } diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx index 6d3487e052..39c8eff514 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx @@ -7,7 +7,7 @@ import { RocketOutlined, SaveOutlined, } from '@ant-design/icons'; -import { Alert, Button, Input, Modal, Popover, Segmented, Space } from 'antd'; +import { Alert, Button, Input, Modal, Segmented, Space, Tooltip } from 'antd'; import React from 'react'; import WorkflowStudioCanvasRegion from '@/pages/team-member-workflow-studio/components/WorkflowStudioCanvasRegion'; import WorkflowStudioNodeLibrary from '@/pages/team-member-workflow-studio/components/WorkflowStudioNodeLibrary'; @@ -61,7 +61,6 @@ type PublicationStage = 'idle' | 'submitting' | 'accepted' | 'failed'; type PublishReadinessIssue = { readonly id: string; readonly message: string; - readonly resolve: () => void; }; function hasNonBlankIdentifier(value: unknown): value is string { @@ -547,11 +546,6 @@ const WorkflowEditorPage: React.FC<{ publication.phase === 'forbidden'); const canRetryPublicationSubmission = publicationReceipt === null && publicationStage === 'failed'; - const revealPublicationStatus = () => { - document - .getElementById('workflow-publication-status') - ?.scrollIntoView?.({ block: 'nearest' }); - }; const blockingFindings = editor.findings.filter( (finding) => String(finding.level).toLowerCase() === 'error', ); @@ -563,7 +557,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.saveBeforePublishing', 'Save this workflow before publishing.', ), - resolve: () => void saveWorkflow(), }); } if (editor.dirty) { @@ -573,7 +566,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.saveChangesBeforePublishing', 'Save your changes before publishing.', ), - resolve: () => void saveWorkflow(), }); } if (hasUnappliedNodeChanges) { @@ -583,29 +575,12 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.applyNodeChanges', 'Apply or discard node configuration before publishing.', ), - resolve: () => setMode('canvas'), }); } for (const [findingIndex, finding] of blockingFindings.entries()) { - const stepMatch = finding.path?.match(/^\/steps\/(\d+)(?:\/|$)/); - const stepId = stepMatch - ? editor.document?.steps?.[Number(stepMatch[1])]?.id - : undefined; publishReadinessIssues.push({ id: `finding-${finding.code}-${finding.path ?? findingIndex}`, message: finding.message, - resolve: () => { - if (finding.path === '/name') { - workflowNameRef.current?.focus(); - return; - } - if (stepId) { - setMode('canvas'); - editor.selectNode(`step:${stepId}`); - return; - } - setMode('yaml'); - }, }); } if (!editor.document?.steps?.length && blockingFindings.length === 0) { @@ -615,10 +590,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.addExecutableStep', 'Add at least one executable step before publishing.', ), - resolve: () => { - setMode('canvas'); - setNodeLibraryOpen(true); - }, }); } if (editor.validating || editor.saving) { @@ -628,7 +599,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.waitForSave', 'Wait for workflow validation and saving to finish.', ), - resolve: () => saveStatusRef.current?.focus(), }); } else if (editor.structuralMutationPending) { publishReadinessIssues.push({ @@ -637,7 +607,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.waitForEditorUpdate', 'Wait for the workflow step update to finish.', ), - resolve: () => setMode('canvas'), }); } else if (editor.receiptPending) { publishReadinessIssues.push({ @@ -646,7 +615,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.waitForSavedDraft', 'Wait for the saved draft to become readable.', ), - resolve: () => saveStatusRef.current?.focus(), }); } if (publicationActionPending) { @@ -656,7 +624,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.waitForPublication', 'Wait for the current publication to finish.', ), - resolve: revealPublicationStatus, }); } else if (publicationObservationPending) { publishReadinessIssues.push({ @@ -665,7 +632,6 @@ const WorkflowEditorPage: React.FC<{ 'workflowActivityVNext.publish.resolvePublication', 'Resolve the current publication status before publishing again.', ), - resolve: revealPublicationStatus, }); } const publicationCurrent = publicationObserved && !publicationStale; @@ -798,30 +764,21 @@ const WorkflowEditorPage: React.FC<{ {t('workflowActivityVNext.common.run', 'Run')} {publishReadinessIssues.length > 0 ? ( - +
    {publishReadinessIssues.map((issue) => ( -
  • - -
  • +
  • {issue.message}
  • ))}
- +
} - trigger={['hover', 'focus', 'click']} + trigger={['hover', 'focus']} > {publishButton} - + ) : ( publishButton )} From 9f7c3bce83aab02feafd43b6fc3cb30de38357e2 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:37:53 +0800 Subject: [PATCH 06/15] Reuse Workflow Studio editor contract --- .../WorkflowStudioEditorSurface.tsx | 52 ++++++ .../hooks/useTeamMemberWorkflowStudio.ts | 9 ++ .../index.test.tsx | 87 ++++++++++ .../team-member-workflow-studio/index.tsx | 38 +++-- .../hooks/useWorkflowEditor.ts | 150 +++++++++++++++++- .../hooks/useWorkflowPublication.test.ts | 57 +++++-- .../hooks/useWorkflowPublication.ts | 119 +++++--------- .../workflow-activity-vnext/index.test.tsx | 133 ++++++++++++---- .../workflows/WorkflowEditorPage.tsx | 69 +++++--- .../workflows/workflowArchival.test.ts | 3 + .../src/shared/api/scopesApi.ts | 15 ++ .../src/shared/models/scopes.ts | 3 + .../src/shared/studio/api.test.ts | 14 +- .../src/shared/studio/api.ts | 5 - .../src/shared/studio/models.ts | 1 - .../ScopeWorkflows/ScopeWorkflowModels.cs | 5 +- .../ScopeWorkflowCommandApplicationService.cs | 5 +- ...eWorkflowCommandApplicationServiceTests.cs | 1 - 18 files changed, 590 insertions(+), 176 deletions(-) create mode 100644 apps/aevatar-console-web/src/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface.tsx diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface.tsx new file mode 100644 index 0000000000..fbaa8749ff --- /dev/null +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import WorkflowStudioCanvasRegion from './WorkflowStudioCanvasRegion'; +import WorkflowStudioNodeLibrary from './WorkflowStudioNodeLibrary'; + +type WorkflowStudioCanvasRegionProps = React.ComponentProps< + typeof WorkflowStudioCanvasRegion +>; + +type WorkflowStudioEditingCallbacks = Required< + Pick< + WorkflowStudioCanvasRegionProps, + | 'onCanvasSelect' + | 'onConnectNodes' + | 'onDeleteEdges' + | 'onDeleteNodes' + | 'onEdgeSelect' + | 'onNodeLayoutChange' + | 'onNodeSelect' + > +>; + +type WorkflowStudioEditorSurfaceProps = Omit< + WorkflowStudioCanvasRegionProps, + keyof WorkflowStudioEditingCallbacks | 'children' +> & + WorkflowStudioEditingCallbacks & { + readonly children?: React.ReactNode; + readonly nodeLibraryOpen: boolean; + readonly onCloseNodeLibrary: () => void; + readonly onInsertNode: (stepType: string) => void; + }; + +const WorkflowStudioEditorSurface: React.FC< + WorkflowStudioEditorSurfaceProps +> = ({ + children, + nodeLibraryOpen, + onCloseNodeLibrary, + onInsertNode, + ...canvasProps +}) => ( + + + {children} + +); + +export default WorkflowStudioEditorSurface; diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts index b968220054..7173c852d4 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts @@ -30,6 +30,7 @@ import { removeStep, removeStepConnection, type StudioStepInspectorDraft, + suggestBranchLabelForStep, } from '@/shared/studio/document'; import { buildExecutionTrace, @@ -3202,10 +3203,18 @@ export function useTeamMemberWorkflowStudio(): TeamMemberWorkflowStudioState { return; } + const sourceStep = editableDocument.steps?.find( + (step) => trimOptional(step.id) === sourceStepId, + ); + const branchLabel = suggestBranchLabelForStep( + trimOptional(sourceStep?.type), + sourceStep?.branches ?? {}, + ); const result = connectStepToTarget( editableDocument, sourceStepId, targetStepId, + branchLabel, ); setEditableDocument(result.document); setSelectedEdgeId(''); diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx index b65081e3be..27783349df 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx @@ -3102,6 +3102,93 @@ describe('TeamMemberWorkflowStudioPage', () => { }); }); + it('connects conditional nodes through the shared branch-aware Studio editor', async () => { + window.history.replaceState( + {}, + '', + '/scopes/scope-1/teams/t-alpha/members/member-alpha/workflow?workflowId=workflow-alpha', + ); + (studioApi.getMember as jest.Mock).mockResolvedValue({ + implementationRef: { + implementationKind: 'workflow', + workflowId: 'workflow-alpha', + }, + summary: { + createdAt: '2026-06-08T00:00:00Z', + description: '', + displayName: 'Workflow Alpha', + implementationKind: 'workflow', + lastBoundRevisionId: null, + lifecycleStage: 'created', + memberId: 'member-alpha', + publishedServiceId: '', + scopeId: 'scope-1', + teamId: 't-alpha', + updatedAt: '2026-06-08T00:00:00Z', + }, + }); + (studioApi.getWorkflow as jest.Mock).mockResolvedValue({ + directoryId: 'scope:scope-1', + directoryLabel: 'scope-1', + draftExists: true, + fileName: 'workflow-alpha.yaml', + filePath: 'scope://scope-1/workflow-alpha.yaml', + findings: [], + layout: null, + name: 'Workflow Alpha', + workflowId: 'workflow-alpha', + yaml: 'name: Workflow Alpha\nsteps: []\n', + document: { + ...mockWorkflowDocument, + steps: [ + { + id: 'condition', + type: 'conditional', + targetRole: '', + parameters: {}, + next: null, + branches: {}, + }, + { + id: 'transform', + type: 'transform', + targetRole: '', + parameters: {}, + next: null, + branches: {}, + }, + ], + }, + updatedAtUtc: '2026-06-08T00:00:00Z', + }); + + renderWithQueryClient(React.createElement(TeamMemberWorkflowStudioPage)); + + await waitFor(() => { + expect(screen.getByTestId('graph-canvas')).toHaveTextContent('nodes:2'); + }); + fireEvent.click( + screen.getByRole('button', { name: 'connect first two nodes' }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(studioApi.serializeYaml).toHaveBeenCalledWith( + expect.objectContaining({ + document: expect.objectContaining({ + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'condition', + branches: { true: 'transform' }, + next: null, + }), + ]), + }), + }), + ); + }); + }); + it('deletes a selected connection without deleting either node', async () => { window.history.replaceState( {}, diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx index 39ff4935d6..e1040ce2a8 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx @@ -1,12 +1,11 @@ import { Alert, Spin } from 'antd'; import React from 'react'; import { t } from '@/shared/i18n/messages'; -import WorkflowStudioCanvas from './components/WorkflowStudioCanvas'; import WorkflowStudioDraftRunPanel from './components/WorkflowStudioDraftRunPanel'; +import WorkflowStudioEditorSurface from './components/WorkflowStudioEditorSurface'; import WorkflowStudioExecutionPanel from './components/WorkflowStudioExecutionPanel'; import WorkflowStudioHeader from './components/WorkflowStudioHeader'; import WorkflowStudioNodeDetailPanel from './components/WorkflowStudioNodeDetailPanel'; -import WorkflowStudioNodeLibrary from './components/WorkflowStudioNodeLibrary'; import WorkflowStudioYamlPanel from './components/WorkflowStudioYamlPanel'; import { useTeamMemberWorkflowStudio } from './hooks/useTeamMemberWorkflowStudio'; @@ -331,12 +330,14 @@ const TeamMemberWorkflowStudioPage: React.FC = () => {
) : ( - { if (edgeIds.includes(studio.selectedEdgeId)) { @@ -349,17 +350,25 @@ const TeamMemberWorkflowStudioPage: React.FC = () => { } }} onEdgeSelect={studio.selectEdge} + onInsertNode={studio.insertNode} onNodeLayoutChange={studio.moveNodes} onNodeSelect={studio.selectNode} selectedEdgeId={studio.selectedEdgeId} selectedNodeId={studio.selectedNodeId} - /> + > + {studio.draftRunPanelOpen || studio.yamlPanelOpen ? null : ( + + )} + )} - {sidePanelOpen ? (
{ open={studio.yamlPanelOpen} width={sidePanelWidth} /> - {studio.draftRunPanelOpen || studio.yamlPanelOpen ? null : ( - - )} {executionPanelOpen ? (
(null); const runInFlightRef = React.useRef(false); const runGenerationRef = React.useRef(0); @@ -233,6 +239,8 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { setSaveError(''); setStructuralMutationError(''); setFailedNodeType(null); + setCanvasMutationError(''); + setSelectedEdgeId(''); setSelectedNodeId(''); setSelectedStepConfigurationError(''); if (source.data.document) return; @@ -372,6 +380,7 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { setStructuralMutationPending(false); setStructuralMutationError(''); setFailedNodeType(null); + setCanvasMutationError(''); setSaveError(''); setRunInput(''); setRunInputError(''); @@ -381,6 +390,7 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { sseRunIdRef.current = ''; setSseRunId(''); setSelectedNodeId(''); + setSelectedEdgeId(''); setSelectedStepConfigurationError(''); }, [materialization.reset], @@ -504,7 +514,8 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { const current = document ?? (await parseCurrentYaml()); if (!current || generation !== structuralMutationGenerationRef.current) return false; - const explicitDocument = materializeImplicitSequentialTransitions(current); + const explicitDocument = + materializeImplicitSequentialTransitions(current); const selectedStepId = selectedNodeId.startsWith('step:') ? selectedNodeId.slice('step:'.length).trim() : ''; @@ -551,6 +562,130 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { () => buildStudioGraphElements(document, layout), [document, layout], ); + const applyCanvasDocumentMutation = React.useCallback( + async ( + mutate: (current: StudioWorkflowDocument) => { + document: StudioWorkflowDocument; + nodeId: string; + }, + ): Promise => { + if (savingRef.current || structuralMutationPendingRef.current) + return false; + const generation = ++structuralMutationGenerationRef.current; + structuralMutationPendingRef.current = true; + setStructuralMutationPending(true); + setCanvasMutationError(''); + try { + const current = document ?? (await parseCurrentYaml()); + if (!current || generation !== structuralMutationGenerationRef.current) + return false; + const result = mutate(current); + const serialized = await studioApi.serializeYaml({ + document: result.document, + }); + if (generation !== structuralMutationGenerationRef.current) + return false; + setDocument(serialized.document); + setYaml(serialized.yaml); + setFindings(serialized.findings); + setSelectedEdgeId(''); + setSelectedNodeId(result.nodeId); + setSelectedStepConfigurationError(''); + markLocalEdit(); + return true; + } catch (error) { + if (generation === structuralMutationGenerationRef.current) { + setCanvasMutationError(toErrorMessage(error)); + } + return false; + } finally { + if (generation === structuralMutationGenerationRef.current) { + structuralMutationPendingRef.current = false; + setStructuralMutationPending(false); + } + } + }, + [document, markLocalEdit, parseCurrentYaml], + ); + const connectNodes = React.useCallback( + (sourceNodeId: string, targetNodeId: string) => + applyCanvasDocumentMutation((current) => { + const currentGraph = buildStudioGraphElements(current, layout); + const sourceStepId = currentGraph.nodes.find( + (node) => node.id === sourceNodeId, + )?.data.stepId; + const targetStepId = currentGraph.nodes.find( + (node) => node.id === targetNodeId, + )?.data.stepId; + if (!sourceStepId || !targetStepId || sourceStepId === targetStepId) { + return { document: current, nodeId: sourceNodeId }; + } + const sourceStep = current.steps?.find( + (step) => String(step.id ?? '').trim() === sourceStepId, + ); + const branchLabel = suggestBranchLabelForStep( + String(sourceStep?.type ?? '').trim(), + sourceStep?.branches ?? {}, + ); + return connectStepToTarget( + current, + sourceStepId, + targetStepId, + branchLabel, + ); + }), + [applyCanvasDocumentMutation, layout], + ); + const deleteNodes = React.useCallback( + (nodeIds: readonly string[]) => + applyCanvasDocumentMutation((current) => { + const currentGraph = buildStudioGraphElements(current, layout); + const stepIds = nodeIds + .map( + (nodeId) => + currentGraph.nodes.find((node) => node.id === nodeId)?.data + .stepId, + ) + .filter((stepId): stepId is string => Boolean(stepId)); + return removeSteps(current, stepIds); + }), + [applyCanvasDocumentMutation, layout], + ); + const deleteEdges = React.useCallback( + (edgeIds: readonly string[]) => + applyCanvasDocumentMutation((current) => { + const currentGraph = buildStudioGraphElements(current, layout); + let result = { document: current, nodeId: selectedNodeId }; + for (const edgeId of edgeIds) { + const edge = currentGraph.edges.find((entry) => entry.id === edgeId); + const sourceStepId = currentGraph.nodes.find( + (node) => node.id === edge?.source, + )?.data.stepId; + const targetStepId = currentGraph.nodes.find( + (node) => node.id === edge?.target, + )?.data.stepId; + if (!sourceStepId || !targetStepId) continue; + result = removeStepConnection( + result.document, + sourceStepId, + targetStepId, + edge?.data?.branchLabel ?? null, + ); + } + return result; + }), + [applyCanvasDocumentMutation, layout, selectedNodeId], + ); + const moveNodes = React.useCallback( + (nodes: ReturnType['nodes']) => { + if (savingRef.current || structuralMutationPendingRef.current) return; + setLayout((current) => + buildStudioWorkflowLayout(workflowTitle, nodes, current), + ); + markLocalEdit(); + }, + [markLocalEdit, workflowTitle], + ); const selectedStepDraft = React.useMemo(() => { const selectedStepId = selectedNodeId.startsWith('step:') ? selectedNodeId.slice('step:'.length).trim() @@ -788,9 +923,14 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { document, findings, graph, + canvasMutationError, + connectNodes, + deleteEdges, + deleteNodes, loading: source.isPending, loadError: source.error, materialization, + moveNodes, nodeInsertionError: structuralMutationError, preparePublication, receiptPending, @@ -813,13 +953,21 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { structuralMutationPending, sseRunId, selectedNodeId, + selectedEdgeId, selectedStepConfigurationError, selectedStepDraft, selectCanvas: () => { + setSelectedEdgeId(''); + setSelectedNodeId(''); + setSelectedStepConfigurationError(''); + }, + selectEdge: (edgeId: string) => { + setSelectedEdgeId(edgeId); setSelectedNodeId(''); setSelectedStepConfigurationError(''); }, selectNode: (nodeId: string) => { + setSelectedEdgeId(''); setSelectedNodeId(nodeId); setSelectedStepConfigurationError(''); }, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts index 151177b8a9..988efada53 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts @@ -11,8 +11,8 @@ const receipt: WorkflowPublicationReceipt = { scopeId: 'scope-alpha', workflowId: 'wf-publication-alpha', revisionId: 'rev-publication-alpha', - publishedServiceId: 'svc-publication-alpha', }; +const publishedServiceId = 'svc-publication-alpha'; function workflowDetail( scopeId = receipt.scopeId, @@ -32,9 +32,12 @@ function workflowDetail( serviceKey: 'workflow-publication', workflowName: 'Publication workflow', actorId, - activeRevisionId: 'workflow-revision-alpha', + activeRevisionId: receipt.revisionId, deploymentId: 'deployment-workflow-alpha', deploymentStatus: 'Available', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId, updatedAt: '2026-08-06T10:00:00Z', }, source: null, @@ -79,7 +82,7 @@ function revisionCatalog( ): ScopeServiceRevisionCatalogSnapshot { return { scopeId: receipt.scopeId, - serviceId: receipt.publishedServiceId, + serviceId: publishedServiceId, serviceKey: 'service-publication', displayName: 'Publication service', defaultServingRevisionId: receipt.revisionId, @@ -125,16 +128,18 @@ describe('observeWorkflowPublication', () => { }); await Promise.resolve(); - expect(revisionsRead).toHaveBeenCalledWith( - 'scope-alpha', - 'svc-publication-alpha', - ); + expect(revisionsRead).not.toHaveBeenCalled(); resolveWorkflow(workflowDetail()); await expect(observation).resolves.toMatchObject({ kind: 'observed', + publishedServiceId, revision: { revisionId: 'rev-publication-alpha' }, }); + expect(revisionsRead).toHaveBeenCalledWith( + 'scope-alpha', + 'svc-publication-alpha', + ); expect(workflowRead).toHaveBeenCalledWith( 'scope-alpha', 'wf-publication-alpha', @@ -179,10 +184,7 @@ describe('observeWorkflowPublication', () => { ['scope-alpha', 'wf-publication-alpha'], ['scope-alpha', 'wf-publication-alpha'], ]); - expect(revisionsRead.mock.calls).toEqual([ - ['scope-alpha', 'svc-publication-alpha'], - ['scope-alpha', 'svc-publication-alpha'], - ]); + expect(revisionsRead).not.toHaveBeenCalled(); }); it('treats a receipt-bound service revision catalog 404 as observation delay', async () => { @@ -203,6 +205,28 @@ describe('observeWorkflowPublication', () => { ); }); + it('waits for the workflow read model to expose its published service identity', async () => { + const detail = workflowDetail(); + const revisionsRead = jest.fn(); + + await expect( + observeWorkflowPublication({ + receipt, + readWorkflow: async () => ({ + ...detail, + workflow: { + ...detail.workflow, + publishedServiceId: '', + }, + }), + readRevisions: revisionsRead, + wait: async () => undefined, + delaysMs: [0], + }), + ).resolves.toEqual({ kind: 'delayed' }); + expect(revisionsRead).not.toHaveBeenCalled(); + }); + it('delays only the recognized workflow projection conflict codes', async () => { for (const code of ['USER_WORKFLOW_NOT_READY', 'USER_WORKFLOW_STALE']) { await expect( @@ -244,6 +268,7 @@ describe('observeWorkflowPublication', () => { { workflow: workflowDetail('scope-other', 'wf-other'), catalog: revisionCatalog([serviceRevision()]), + expectedRevisionCalls: 0, }, { workflow: { @@ -254,6 +279,7 @@ describe('observeWorkflowPublication', () => { }, }, catalog: revisionCatalog([serviceRevision()]), + expectedRevisionCalls: 0, }, { workflow: workflowDetail(), @@ -261,6 +287,7 @@ describe('observeWorkflowPublication', () => { scopeId: 'scope-other', serviceId: 'svc-other', }), + expectedRevisionCalls: 1, }, ]; @@ -278,7 +305,9 @@ describe('observeWorkflowPublication', () => { }), ).rejects.toThrow('does not match'); expect(workflowRead).toHaveBeenCalledTimes(1); - expect(revisionsRead).toHaveBeenCalledTimes(1); + expect(revisionsRead).toHaveBeenCalledTimes( + candidate.expectedRevisionCalls, + ); } }); @@ -356,9 +385,7 @@ describe('observeWorkflowPublication', () => { ['failure reason', serviceRevision({ failureReason: 'artifact rejected' })], ['Retired', serviceRevision({ status: ' RETIRED ' })], ])('stops when the accepted revision reaches terminal %s state', async (_, revision) => { - const workflowRead = jest - .fn() - .mockRejectedValue(httpStatusError(409, 'USER_WORKFLOW_NOT_READY')); + const workflowRead = jest.fn().mockResolvedValue(workflowDetail()); const revisionsRead = jest .fn() .mockResolvedValue(revisionCatalog([revision])); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts index 18b4338470..dba2a09d3f 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts @@ -16,7 +16,6 @@ const DELAYED_WORKFLOW_CONFLICT_CODES = new Set([ ]); export type WorkflowPublicationReceipt = { - readonly publishedServiceId: string; readonly scopeId: string; readonly workflowId: string; readonly revisionId: string; @@ -40,6 +39,7 @@ export type WorkflowPublicationObservationResult = | { readonly kind: 'observed'; readonly catalog: ScopeServiceRevisionCatalogSnapshot; + readonly publishedServiceId: string; readonly revision: StudioScopeBindingRevision; readonly workflow: ScopeWorkflowDetail; } @@ -122,11 +122,12 @@ function assertAcceptedWorkflowIdentity( function assertAcceptedCatalogIdentity( receipt: WorkflowPublicationReceipt, + publishedServiceId: string, catalog: ScopeServiceRevisionCatalogSnapshot, ): void { if ( catalog.scopeId !== receipt.scopeId || - catalog.serviceId !== receipt.publishedServiceId + catalog.serviceId !== publishedServiceId ) { throw new Error( 'The observed service catalog does not match the accepted service.', @@ -150,6 +151,7 @@ function assertRevisionImplementsWorkflow( function matchesAcceptedPublication( receipt: WorkflowPublicationReceipt, + publishedServiceId: string, workflow: ScopeWorkflowDetail, catalog: ScopeServiceRevisionCatalogSnapshot, revision: StudioScopeBindingRevision, @@ -159,8 +161,9 @@ function matchesAcceptedPublication( workflow.scopeId === receipt.scopeId && workflow.workflow?.scopeId === receipt.scopeId && workflow.workflow?.workflowId === receipt.workflowId && + workflow.workflow?.activeRevisionId === receipt.revisionId && catalog.scopeId === receipt.scopeId && - catalog.serviceId === receipt.publishedServiceId && + catalog.serviceId === publishedServiceId && catalog.activeServingRevisionId === receipt.revisionId && revision.revisionId === receipt.revisionId && revision.implementationKind === 'workflow' && @@ -181,49 +184,6 @@ function isDelayedWorkflowRead(error: unknown): boolean { ); } -function observationError( - workflowResult: PromiseSettledResult, - catalogResult: PromiseSettledResult, -): unknown | null { - const errors: unknown[] = []; - if (workflowResult.status === 'rejected') errors.push(workflowResult.reason); - if (catalogResult.status === 'rejected') errors.push(catalogResult.reason); - - const unauthorized = errors.find((error) => statusOf(error) === 401); - if (unauthorized) return unauthorized; - - const forbidden = errors.find((error) => statusOf(error) === 403); - if (forbidden) return forbidden; - - if ( - workflowResult.status === 'rejected' && - !isDelayedWorkflowRead(workflowResult.reason) - ) { - return workflowResult.reason; - } - if ( - catalogResult.status === 'rejected' && - statusOf(catalogResult.reason) !== 404 - ) { - return catalogResult.reason; - } - - return null; -} - -function isDelayedObservation( - workflowResult: PromiseSettledResult, - catalogResult: PromiseSettledResult, -): boolean { - const catalogStatus = - catalogResult.status === 'rejected' ? statusOf(catalogResult.reason) : 0; - return ( - (workflowResult.status === 'rejected' && - isDelayedWorkflowRead(workflowResult.reason)) || - catalogStatus === 404 - ); -} - export async function observeWorkflowPublication( input: WorkflowPublicationObservationInput, ): Promise { @@ -233,22 +193,34 @@ export async function observeWorkflowPublication( for (const delayMs of delays) { if (delayMs > 0) await wait(delayMs); - const [workflowResult, catalogResult] = await Promise.allSettled([ - input.readWorkflow(input.receipt.scopeId, input.receipt.workflowId), - input.readRevisions( + let workflow: ScopeWorkflowDetail; + try { + workflow = await input.readWorkflow( input.receipt.scopeId, - input.receipt.publishedServiceId, - ), - ]); - const error = observationError(workflowResult, catalogResult); - if (error) throw error; - - const workflow = - workflowResult.status === 'fulfilled' ? workflowResult.value : null; - const catalog = - catalogResult.status === 'fulfilled' ? catalogResult.value : null; - if (workflow) assertAcceptedWorkflowIdentity(input.receipt, workflow); - if (catalog) assertAcceptedCatalogIdentity(input.receipt, catalog); + input.receipt.workflowId, + ); + } catch (error) { + if (isDelayedWorkflowRead(error)) continue; + throw error; + } + assertAcceptedWorkflowIdentity(input.receipt, workflow); + + const publishedServiceId = + workflow.workflow?.publishedServiceId.trim() ?? ''; + if (!workflow.available || !workflow.workflow || !publishedServiceId) + continue; + + let catalog: ScopeServiceRevisionCatalogSnapshot; + try { + catalog = await input.readRevisions( + input.receipt.scopeId, + publishedServiceId, + ); + } catch (error) { + if (statusOf(error) === 404) continue; + throw error; + } + assertAcceptedCatalogIdentity(input.receipt, publishedServiceId, catalog); const revision = catalog?.revisions.find( (candidate) => candidate.revisionId === input.receipt.revisionId, @@ -258,31 +230,25 @@ export async function observeWorkflowPublication( 'The accepted workflow publication reached a terminal revision state.', ); } - if (workflow && revision) { + if (revision) { assertRevisionImplementsWorkflow(workflow, revision); } - if (isDelayedObservation(workflowResult, catalogResult)) continue; - if ( - workflowResult.status !== 'fulfilled' || - catalogResult.status !== 'fulfilled' - ) { - continue; - } - if (!revision) continue; if ( matchesAcceptedPublication( input.receipt, - workflowResult.value, - catalogResult.value, + publishedServiceId, + workflow, + catalog, revision, ) ) { return { kind: 'observed', - workflow: workflowResult.value, - catalog: catalogResult.value, + workflow, + catalog, + publishedServiceId, revision, }; } @@ -322,7 +288,6 @@ export function useWorkflowPublication( receipt?.scopeId ?? '', receipt?.workflowId ?? '', receipt?.revisionId ?? '', - receipt?.publishedServiceId ?? '', ], queryFn: () => { if (!receipt) { @@ -357,6 +322,10 @@ export function useWorkflowPublication( error: query.error, phase, receipt, + publishedServiceId: + phase === 'observed' && query.data?.kind === 'observed' + ? query.data.publishedServiceId + : '', retry, revision: phase === 'observed' && query.data?.kind === 'observed' 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 index 3616f09f89..5021cdde3a 100644 --- 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 @@ -277,12 +277,33 @@ jest.mock( __esModule: true, default: ({ nodes, + onConnectNodes, + onDeleteEdges, + onDeleteNodes, + onEdgeSelect, + onNodeLayoutChange, onNodeSelect, }: { nodes: readonly { readonly id: string }[]; + onConnectNodes?: (sourceNodeId: string, targetNodeId: string) => void; + onDeleteEdges?: (edgeIds: string[]) => Promise | void; + onDeleteNodes?: (nodeIds: string[]) => Promise | void; + onEdgeSelect?: (edgeId: string) => void; + onNodeLayoutChange?: ( + nodes: readonly { + readonly id: string; + readonly position?: { readonly x: number; readonly y: number }; + }[], + ) => void; onNodeSelect?: (nodeId: string) => void; }) => ( -
+
{nodes.map((node) => (
), }), @@ -2696,7 +2724,6 @@ describe('Workflow Activity vNext editor', () => { mockStudioApi.publishWorkflow.mockResolvedValue({ scopeId: 'scope-alpha', workflowId: 'wf-draft-alpha', - publishedServiceId: 'svc-alpha', serviceKey: 'service-alpha', revisionId: 'rev-preview-alpha', acceptanceStage: 'accepted', @@ -2712,9 +2739,12 @@ describe('Workflow Activity vNext editor', () => { serviceKey: 'workflow-alpha', workflowName: 'Workflow alpha', actorId: 'actor-workflow-alpha', - activeRevisionId: 'workflow-revision-alpha', + activeRevisionId: 'rev-preview-alpha', deploymentId: 'deployment-workflow-alpha', deploymentStatus: 'Available', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-alpha', updatedAt: '2026-08-06T10:00:00Z', }, source: null, @@ -2837,16 +2867,9 @@ describe('Workflow Activity vNext editor', () => { returnedWorkflowId: 'wf-returned-other', mismatch: 'workflow ID', }, - { - returnedRevisionId: 'rev-preview-alpha', - returnedWorkflowId: 'wf-draft-alpha', - returnedPublishedServiceId: '', - mismatch: 'published service ID', - }, ])('keeps a returned $mismatch mismatch visible without starting observation', async ({ returnedRevisionId, returnedWorkflowId, - returnedPublishedServiceId = 'svc-alpha', }) => { mockLocation = '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-draft-alpha'; @@ -2914,7 +2937,6 @@ describe('Workflow Activity vNext editor', () => { scopeId: 'scope-alpha', workflowId: returnedWorkflowId, revisionId: returnedRevisionId, - publishedServiceId: returnedPublishedServiceId, serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', acceptanceStage: 'accepted', propagationStage: 'readmodel_propagating', @@ -3000,7 +3022,6 @@ describe('Workflow Activity vNext editor', () => { scopeId: 'scope-alpha', workflowId: 'wf-draft-alpha', revisionId: 'rev-preview-alpha', - publishedServiceId: 'svc-alpha', serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', acceptanceStage: 'accepted', propagationStage: 'readmodel_propagating', @@ -3019,9 +3040,12 @@ describe('Workflow Activity vNext editor', () => { serviceKey: 'workflow-alpha', workflowName: 'Workflow alpha', actorId: 'actor-workflow-alpha', - activeRevisionId: 'workflow-revision-alpha', + activeRevisionId: 'rev-preview-alpha', deploymentId: 'deployment-workflow-alpha', deploymentStatus: 'Available', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-alpha', updatedAt: '2026-08-06T10:00:00Z', }, source: null, @@ -3158,9 +3182,12 @@ describe('Workflow Activity vNext editor', () => { serviceKey: 'workflow-alpha', workflowName: 'Workflow alpha', actorId: 'actor-workflow-alpha', - activeRevisionId: 'workflow-revision-alpha', + activeRevisionId: 'rev-preview-alpha', deploymentId: 'deployment-workflow-alpha', deploymentStatus: 'Available', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-alpha', updatedAt: '2026-08-06T10:00:00Z', }, source: null, @@ -3227,7 +3254,7 @@ describe('Workflow Activity vNext editor', () => { 'scope-alpha', 'wf-draft-alpha', ); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenCalledTimes(2); + expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenCalledTimes(1); expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenLastCalledWith( 'scope-alpha', 'svc-alpha', @@ -3262,11 +3289,7 @@ describe('Workflow Activity vNext editor', () => { 'scope-alpha', 'wf-draft-alpha', ); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenCalledTimes(2); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenLastCalledWith( - 'scope-alpha', - 'svc-alpha', - ); + expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(1); expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1); }); @@ -3294,7 +3317,6 @@ describe('Workflow Activity vNext editor', () => { scopeId: 'scope-alpha', workflowId: 'wf-draft-alpha', revisionId: receiptRevisionId, - publishedServiceId: 'svc-alpha', serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', acceptanceStage: 'accepted', propagationStage: 'readmodel_propagating', @@ -3303,7 +3325,6 @@ describe('Workflow Activity vNext editor', () => { scopeId: 'scope-alpha', workflowId: 'wf-draft-alpha', revisionId: freshRevisionId, - publishedServiceId: 'svc-alpha', serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', acceptanceStage: 'accepted', propagationStage: 'readmodel_propagating', @@ -3322,9 +3343,12 @@ describe('Workflow Activity vNext editor', () => { serviceKey: 'workflow-alpha', workflowName: 'Workflow alpha', actorId: 'actor-workflow-alpha', - activeRevisionId: 'workflow-revision-beta', + activeRevisionId: freshRevisionId, deploymentId: 'deployment-workflow-alpha', deploymentStatus: 'Available', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-alpha', updatedAt: '2026-08-06T10:00:00Z', }, source: null, @@ -3428,7 +3452,7 @@ describe('Workflow Activity vNext editor', () => { await jest.advanceTimersByTimeAsync(0); }); expect(mockScopesApi.getWorkflowDetail).toHaveBeenCalledTimes(1); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenCalledTimes(1); + expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); await act(async () => { await jest.advanceTimersByTimeAsync(5_000); }); @@ -3446,11 +3470,7 @@ describe('Workflow Activity vNext editor', () => { 'scope-alpha', 'wf-draft-alpha', ); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenCalledTimes(10); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenLastCalledWith( - 'scope-alpha', - 'svc-alpha', - ); + expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(1); expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1); } finally { @@ -3831,6 +3851,61 @@ describe('Workflow Activity vNext editor', () => { ).toBeInTheDocument(); }); + it('reuses the complete Studio canvas editing contract', async () => { + const sourceDocument = { + name: 'committed_source', + roles: [], + steps: [ + { id: 'step-root', type: 'conditional' }, + { id: 'step-next', type: 'transform' }, + ], + }; + mockStudioApi.getWorkflow.mockResolvedValue({ + workflowId: 'wf-committed-source', + name: 'Committed source', + fileName: 'committed-source.yaml', + filePath: '', + directoryId: '', + directoryLabel: '', + yaml: 'name: committed_source\nroles: []\nsteps: []\n', + updatedAtUtc: '2026-08-04T10:00:00Z', + document: sourceDocument, + draftExists: false, + findings: [], + }); + mockStudioApi.serializeYaml.mockImplementation(async ({ document }) => ({ + yaml: 'name: committed_source\nroles: []\nsteps: []\n', + document, + findings: [], + })); + + renderWithQueryClient(); + + const canvas = await screen.findByTestId('workflow-studio-canvas'); + expect(canvas).toHaveAttribute('data-connectable', 'true'); + expect(canvas).toHaveAttribute('data-deletable', 'true'); + expect(canvas).toHaveAttribute('data-edge-selectable', 'true'); + expect(canvas).toHaveAttribute('data-layout-editable', 'true'); + + fireEvent.click( + within(canvas).getByRole('button', { name: 'Connect first two nodes' }), + ); + + await waitFor(() => + expect(mockStudioApi.serializeYaml).toHaveBeenCalledWith({ + document: expect.objectContaining({ + steps: expect.arrayContaining([ + expect.objectContaining({ + id: 'step-root', + branches: { true: 'step-next' }, + next: null, + }), + ]), + }), + }), + ); + }); + it('keeps the Canvas/YAML editor view switch discoverable and keyboard operable', async () => { renderWithQueryClient(); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx index 39c8eff514..e6f1a8b67a 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx @@ -9,8 +9,7 @@ import { } from '@ant-design/icons'; import { Alert, Button, Input, Modal, Segmented, Space, Tooltip } from 'antd'; import React from 'react'; -import WorkflowStudioCanvasRegion from '@/pages/team-member-workflow-studio/components/WorkflowStudioCanvasRegion'; -import WorkflowStudioNodeLibrary from '@/pages/team-member-workflow-studio/components/WorkflowStudioNodeLibrary'; +import WorkflowStudioEditorSurface from '@/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface'; import { formatUtcDateTime } from '@/shared/datetime/dateTime'; import { t } from '@/shared/i18n/messages'; import { getLocationSnapshot, history } from '@/shared/navigation/history'; @@ -113,8 +112,14 @@ const WorkflowEditorPage: React.FC<{ publishedDocumentVersion !== editor.documentVersion, ); const publishedInvocationTarget = - publicationReceipt && publicationObserved && !publicationStale - ? publicationReceipt + publicationReceipt && + publicationObserved && + !publicationStale && + publication.publishedServiceId + ? { + ...publicationReceipt, + publishedServiceId: publication.publishedServiceId, + } : null; const canOpenPublishedRun = Boolean( publishedInvocationTarget && @@ -305,6 +310,16 @@ const WorkflowEditorPage: React.FC<{ ); }, [editor.nodeInsertionError, editor.retryNodeInsertion, toast]); + React.useEffect(() => { + if (!editor.canvasMutationError) return; + toast.error( + t( + 'workflowActivityVNext.editor.canvasUpdateFailed', + "Couldn't update workflow", + ), + ); + }, [editor.canvasMutationError, toast]); + const retryMaterialization = React.useCallback(async () => { await editor.retryMaterialization(); }, [editor.retryMaterialization]); @@ -382,8 +397,7 @@ const WorkflowEditorPage: React.FC<{ !hasNonBlankIdentifier(result.workflowId) || result.workflowId !== preparation.workflowId || !hasNonBlankIdentifier(result.revisionId) || - result.revisionId !== preview.revisionId || - !hasNonBlankIdentifier(result.publishedServiceId) + result.revisionId !== preview.revisionId ) { throw new Error( 'The accepted publication response does not match the submitted workflow.', @@ -391,7 +405,6 @@ const WorkflowEditorPage: React.FC<{ } setPublicationReceipt({ - publishedServiceId: result.publishedServiceId, scopeId: result.scopeId, revisionId: result.revisionId, workflowId: result.workflowId, @@ -975,7 +988,7 @@ const WorkflowEditorPage: React.FC<{
) : null} {mode === 'canvas' ? ( - { if (!editorWriteLocked) setNodeLibraryOpen(true); }} onCanvasSelect={requestCanvasSelect} + onConnectNodes={(sourceNodeId, targetNodeId) => { + requestInspectorDiscard(() => { + void editor.connectNodes(sourceNodeId, targetNodeId); + }); + }} + onCloseNodeLibrary={() => setNodeLibraryOpen(false)} + onDeleteEdges={(edgeIds) => { + requestInspectorDiscard(() => { + void editor.deleteEdges(edgeIds); + }); + }} + onDeleteNodes={(nodeIds) => { + requestInspectorDiscard(() => { + void editor.deleteNodes(nodeIds); + }); + }} + onEdgeSelect={(edgeId) => { + requestInspectorDiscard(() => editor.selectEdge(edgeId)); + }} + onInsertNode={(stepType) => { + requestInspectorDiscard(() => { + void editor.addNode(stepType); + setNodeLibraryOpen(false); + }); + }} + onNodeLayoutChange={editor.moveNodes} onNodeSelect={requestNodeSelect} + selectedEdgeId={editor.selectedEdgeId} selectedNodeId={editor.selectedNodeId} style={{ border: '1px solid var(--wa-line)', @@ -1010,16 +1051,6 @@ const WorkflowEditorPage: React.FC<{ > {t('workflowActivityVNext.editor.addNode', 'Add node')} - setNodeLibraryOpen(false)} - onInsertNode={(stepType) => { - requestInspectorDiscard(() => { - void editor.addNode(stepType); - setNodeLibraryOpen(false); - }); - }} - open={nodeLibraryOpen && !editorWriteLocked} - /> - + ) : ( { activeRevisionId: 'rev-draft', deploymentId: 'dep-draft', deploymentStatus: 'Running', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-workflow-draft', updatedAt: '2026-04-15T00:00:00Z', }, { @@ -601,6 +604,9 @@ describe('studioApi host-session requests', () => { activeRevisionId: 'rev-published', deploymentId: 'dep-published', deploymentStatus: 'Running', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-workflow-published', updatedAt: '2026-04-14T00:00:00Z', }, ], @@ -686,6 +692,9 @@ describe('studioApi host-session requests', () => { activeRevisionId: 'rev-1', deploymentId: 'dep-1', deploymentStatus: 'Pending', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-workflow-1', updatedAt: '2026-04-16T00:00:00Z', }, source: { @@ -752,6 +761,9 @@ describe('studioApi host-session requests', () => { activeRevisionId: 'rev-2', deploymentId: 'dep-1', deploymentStatus: 'Running', + serviceAppId: 'studio', + serviceNamespace: 'workflow-publications', + publishedServiceId: 'svc-workflow-1', updatedAt: '2026-04-17T00:00:00Z', }, source: { @@ -1432,7 +1444,6 @@ describe('studioApi host-session requests', () => { json: async () => ({ scopeId: 'scope-alpha', workflowId: 'wf-alpha', - publishedServiceId: 'svc-workflow-alpha', serviceKey: 'scope-alpha:default:default:svc-workflow-alpha', revisionId: 'rev-alpha', definitionActorIdPrefix: 'workflow-definition-alpha', @@ -1461,7 +1472,6 @@ describe('studioApi host-session requests', () => { expect.objectContaining({ scopeId: 'scope-alpha', workflowId: 'wf-alpha', - publishedServiceId: 'svc-workflow-alpha', revisionId: 'rev-alpha', }), ); diff --git a/apps/aevatar-console-web/src/shared/studio/api.ts b/apps/aevatar-console-web/src/shared/studio/api.ts index 99b717397e..9bac526186 100644 --- a/apps/aevatar-console-web/src/shared/studio/api.ts +++ b/apps/aevatar-console-web/src/shared/studio/api.ts @@ -1972,11 +1972,6 @@ function decodeStudioPublishWorkflowResult( ['workflowId', 'WorkflowId'], 'StudioPublishWorkflowAcceptedResult.workflowId', ), - publishedServiceId: readString( - record, - ['publishedServiceId', 'PublishedServiceId'], - 'StudioPublishWorkflowAcceptedResult.publishedServiceId', - ), serviceKey: readString( record, ['serviceKey', 'ServiceKey'], diff --git a/apps/aevatar-console-web/src/shared/studio/models.ts b/apps/aevatar-console-web/src/shared/studio/models.ts index 0d576e3d27..67b62485e7 100644 --- a/apps/aevatar-console-web/src/shared/studio/models.ts +++ b/apps/aevatar-console-web/src/shared/studio/models.ts @@ -300,7 +300,6 @@ export interface StudioSaveAndBindWorkflowAcceptedResult { export interface StudioPublishWorkflowAcceptedResult { readonly scopeId: string; readonly workflowId: string; - readonly publishedServiceId: string; readonly serviceKey: string; readonly revisionId: string; readonly acceptanceStage: string; diff --git a/src/platform/Aevatar.GAgentService.Abstractions/ScopeWorkflows/ScopeWorkflowModels.cs b/src/platform/Aevatar.GAgentService.Abstractions/ScopeWorkflows/ScopeWorkflowModels.cs index 707e929205..f16fc9826e 100644 --- a/src/platform/Aevatar.GAgentService.Abstractions/ScopeWorkflows/ScopeWorkflowModels.cs +++ b/src/platform/Aevatar.GAgentService.Abstractions/ScopeWorkflows/ScopeWorkflowModels.cs @@ -97,10 +97,7 @@ public sealed record ScopeWorkflowUpsertResult( string AcceptanceStage = "accepted", string PropagationStage = "readmodel_propagating", string DisplayName = "", - string WorkflowName = "") -{ - public string PublishedServiceId { get; init; } = string.Empty; -} + string WorkflowName = ""); public sealed record ScopeWorkflowSaveAndBindResult( string ScopeId, diff --git a/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowCommandApplicationService.cs b/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowCommandApplicationService.cs index d0e8f88665..544f4e2323 100644 --- a/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowCommandApplicationService.cs +++ b/src/platform/Aevatar.GAgentService.Application/Workflows/ScopeWorkflowCommandApplicationService.cs @@ -190,10 +190,7 @@ await _serviceCommandPort.ActivateServiceRevisionAsync(new ActivateServiceRevisi commandHandles, BuildReadModelUrl(normalizedScopeId, normalizedWorkflowId), DisplayName: desiredDisplayName, - WorkflowName: ScopeWorkflowCapabilityConventions.NormalizeOptional(request.WorkflowName)) - { - PublishedServiceId = identity.ServiceId, - }; + WorkflowName: ScopeWorkflowCapabilityConventions.NormalizeOptional(request.WorkflowName)); } private static string BuildReadModelUrl(string scopeId, string workflowId) => diff --git a/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowCommandApplicationServiceTests.cs b/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowCommandApplicationServiceTests.cs index 255430215c..c99872e8f2 100644 --- a/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowCommandApplicationServiceTests.cs +++ b/test/Aevatar.GAgentService.Tests/Application/ScopeWorkflowCommandApplicationServiceTests.cs @@ -51,7 +51,6 @@ public async Task UpsertAsync_ShouldCreateServiceAndFullRevisionLifecycle_WhenNe commandPort.Calls[5].Method.Should().Be("ActivateServiceRevisionAsync"); result.ScopeId.Should().Be(ScopeId); result.WorkflowId.Should().Be(WorkflowId); - result.PublishedServiceId.Should().Be(WorkflowId); result.AcceptanceStage.Should().Be("accepted"); result.PropagationStage.Should().Be("readmodel_propagating"); result.ReadModelUrl.Should().Be($"/api/scopes/{ScopeId}/workflows/{WorkflowId}"); From 4c1662d48e421ae6af8c5a2e27aba286dfc8356c Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:51:15 +0800 Subject: [PATCH 07/15] Define workflow editor toast and publish contract --- ...ditor-toast-and-publish-contract-design.md | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md diff --git a/docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md b/docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md new file mode 100644 index 0000000000..43565bac91 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md @@ -0,0 +1,290 @@ +# Workflow Editor Toast And Publish Contract Design + +## Context + +Workflow Activity vNext currently renders publication, save materialization, and +workflow validation failures as full-width page alerts. The UI therefore makes +transient failures and non-blocking validation findings occupy the primary +editing surface. It also keeps retry actions inside those alerts, so hiding the +alerts without moving the recovery actions would make the workflow impossible +to recover. + +The current vNext publication orchestration also differs from Team Member +Workflow Studio. Team Member Studio saves dirty drafts during Publish, +serializes and validates the exact document being published, previews explicit +requests, asks for confirmation when required, dispatches a member binding +command, and observes the authoritative binding run. Workflow Activity vNext +requires a separately saved draft and currently constructs explicit request +confirmations without using the shared confirmation helper. + +This design separates the immediate UI correction from the later publication +contract decision. + +## Semantic Mismatch + +The UI implies that errors and warnings are persistent editor content, while +the product expects them to be transient notifications that do not resize or +displace the workflow canvas. + +Classification: + +- `placement`: errors and warnings occupy the primary editing surface. +- `runtime`: an accepted publish command is followed by an observation process, + but the UI can present an observation delay as a publication failure. +- `contract`: `memberId`, `workflowId`, and `publishedServiceId` are separate + resource identities, while a literal copy of the Team Member API call would + cross those boundaries. +- `mental-model`: Publish should behave as one command with honest asynchronous + status, not as a modal service-selection workflow or a permanent error panel. + +## Scope For This Change + +This change will: + +1. Rebase the existing PR branch onto + `origin/feat/2026-08-04_workflow-activity-vnext` at `cac625168` or its newer + fetched descendant. +2. Adopt the latest shared `ConsoleToast` implementation from the base branch. +3. Remove full-width publication error alerts and save-materialization error + alerts from Workflow Editor. +4. Remove inline workflow finding alerts, including non-blocking yellow + warnings. +5. Present workflow errors as error toasts and workflow warnings as warning + toasts. +6. Preserve recovery actions such as `Retry` and `Check again` inside the + corresponding toast or an existing command/status control. +7. Keep the current Publish submission and observation implementation unchanged + until the frontend and backend agree on the contract described below. + +This change will not: + +- switch Workflow Activity vNext to a member API; +- change the Publish endpoint or accepted response; +- add `publishedServiceId` to `ScopeWorkflowUpsertResult`; +- change warning severity or make non-blocking warnings block Publish; +- add a new service-selection dialog; +- claim that an accepted command is already published. + +## Error And Warning Presentation + +### Ownership + +The editor canvas owns workflow content. Toasts own transient errors and +warnings. The toolbar status owns stable save and publication progress. + +### Rules + +- A failed command or query is shown with `ConsoleToast.error`. +- A validation finding with error severity is shown with + `ConsoleToast.error`. +- A validation finding with warning severity is shown with + `ConsoleToast.warning`. +- Findings are deduplicated by a stable key derived from code, path, severity, + and message so rerenders do not create repeated notifications. +- A delayed asynchronous observation is a warning, not a failed publish. +- A successful save or publish may use an existing success toast or toolbar + status; it does not need a full-width success alert. +- Retry actions move with the notification. Removing an alert must not remove + the user's recovery path. +- Technical details may be included in the toast content when useful, but they + must not create a permanent page region. + +### Expected Editor Layout + +The area between the editor toolbar and the canvas contains no publication, +materialization, validation-error, or validation-warning alert bands. The +canvas keeps a stable vertical position while toasts appear in the shared +top-right console notification surface. + +## Correct Publish Product Flow + +The following flow is the target contract for frontend/backend discussion. It +is not implemented by this change. + +### Resource Identities + +- `memberId` identifies Team Member authority. Only member endpoints accept it. +- `workflowId` identifies an editable workflow draft or definition. Workflow + draft and workflow publication endpoints accept it. +- `publishedServiceId` identifies the callable published service. Service + revision and invocation queries accept it. +- `revisionId` identifies one immutable publication attempt/artifact revision. +- `commandId` and `correlationId` trace a command; they are not resource IDs. + +No frontend code may derive one identity from another by equality, prefix, +route position, service key parsing, or naming convention. + +### 1. Prepare The Exact Draft + +When the user selects Publish: + +1. Apply or explicitly reject unapplied node-inspector changes. +2. Serialize the current document and validate the serialized result. +3. Block only on error-level findings. Emit warning findings as warning toasts. +4. If the document is dirty, save it through the workflow draft API. +5. Observe the exact saved draft/version before publishing, or let a single + backend command atomically accept the saved content and publication intent. + +The published bytes must be the same bytes that were validated and reviewed. + +### 2. Review Explicit Requests + +The frontend generates a fresh opaque `revisionId` candidate and calls the +typed explicit-request preview endpoint with `scopeId`, `workflowId`, +`revisionId`, and the exact workflow YAML. + +- If there are no explicit requests, continue without a dialog. +- If explicit requests exist, reuse + `confirmInteractiveExplicitRequestPreview`. +- If the user cancels, return to idle without an error toast. +- The backend must verify confirmations against the same workflow and revision; + the frontend confirmation is not authority by itself. + +### 3. Dispatch A Workflow Publish Command + +Workflow Activity vNext dispatches a workflow publication command, not a member +binding command. A suggested request contract is: + +```text +scopeId +workflowId +revisionId +workflowName +displayName +workflowYaml or savedDraftVersion +explicitRequestConfirmations[] +``` + +The response is an honest accepted receipt: + +```text +acceptanceStage = accepted +scopeId +workflowId +revisionId +acceptedAtUtc +commandHandles[] { + stage + targetActorId + commandId + correlationId +} +readModelUrl +``` + +The accepted response means only that the command entered the target actor +inbox. It must not imply that the revision is committed, projected, serving, or +readable. + +If the backend can authoritatively allocate `publishedServiceId` before +dispatch, it may return that opaque ID. The frontend must not require it in the +accepted receipt. The current `feature/integrate` contract does not return it. + +### 4. Commit And Project Authoritative State + +The workflow authority commits the publication state and emits the committed +fact into the standard projection pipeline. The projection materializes a typed +workflow publication read model containing at least: + +```text +scopeId +workflowId +revisionId +publicationState +publishedServiceId +definitionActorId +failureCode/failureReason when terminally failed +authoritativeStateVersion +updatedAtUtc +``` + +Recommended states are `accepted`, `preparing`, `published`, and `failed`. +`accepted` may remain command-side only if the read model starts at +`preparing`. + +The most direct API is either: + +```text +GET /api/scopes/:scopeId/workflows/:workflowId +``` + +with a typed current publication sub-message, or: + +```text +GET /api/scopes/:scopeId/workflows/:workflowId/publications/:revisionId +``` + +backed by the same workflow-owned current-state projection. The query must not +prime projections or replay the event store. + +### 5. Observe Without Re-Publishing + +After receiving an accepted receipt, the frontend polls or subscribes to the +exact `scopeId + workflowId + revisionId` publication state. + +- `preparing`: show `Publishing` in the command/status control. +- `published`: store the typed `publishedServiceId`, show a success toast, and + enable run/invoke only when the serving revision is available. +- `failed`: show one error toast with the authoritative failure reason and allow + a new Publish attempt. +- observation timeout: show a warning toast and expose `Check status`; do not + submit another publish command. +- `401/403`: show an authorization error toast and require the corresponding + authentication or permission recovery. + +If the workflow read model exposes only `publishedServiceId`, the frontend may +then query the exact service revision catalog to confirm that `revisionId` is +active and serving. A typed publication state on the workflow read model is +preferred because it avoids making the frontend infer one business state from +two independently delayed query replicas. + +### 6. Idempotency And Retry + +- Before an accepted receipt exists, the same `revisionId` and idempotency key + may be retried according to the command contract. +- After an accepted receipt exists, `Check status` only re-observes; it never + dispatches a duplicate Publish command. +- A new Publish attempt uses a new `revisionId` only after the prior attempt is + terminally failed or the user has changed the saved document. +- Repeated projection writes are idempotent and older authoritative versions + cannot overwrite newer ones. + +## Why Team Member Publish Cannot Be Copied Literally + +Team Member Studio publishes member authority by calling +`bindMemberWorkflow(scopeId, memberId, workflowId, ...)` and observing a member +binding run. Workflow Activity vNext edits a workflow resource and does not +have a canonical `memberId` in its route or draft contract. Calling the member +endpoint with `workflowId` would violate the repository identity rules. + +The reusable part is the orchestration pattern: + +```text +save exact draft +-> serialize and validate +-> preview explicit requests +-> obtain required confirmations +-> dispatch the correct resource command +-> observe authoritative status +-> toast success/failure +``` + +Only the resource-specific dispatch and observation adapters differ. + +## Test Contract For The Immediate UI Change + +Focused tests must prove: + +- publication failure does not render a page-level alert; +- publication failure emits an error toast; +- delayed publication emits a warning toast and retains `Check again`; +- save materialization failure emits an error toast and retains `Try again`; +- error-level findings emit error toasts and are absent from the page flow; +- warning-level findings emit warning toasts and are absent from the page flow; +- repeated renders do not duplicate the same finding toast; +- the canvas and editor controls remain available after notifications; +- the existing Publish API request and observation calls are unchanged. + +Only related Jest files, changed-file Biome checks, the frontend stability +guard, and baseline verification run locally. Full frontend test, typecheck, +and production build remain delegated to GitHub CI. From f008d8a2d79d6385064b94beb51b9e9e41381032 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:46:00 +0800 Subject: [PATCH 08/15] Define hidden workflow publication authorities --- ...ditor-toast-and-publish-contract-design.md | 251 +++++++----------- 1 file changed, 102 insertions(+), 149 deletions(-) diff --git a/docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md b/docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md index 43565bac91..8afdbede10 100644 --- a/docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md +++ b/docs/superpowers/specs/2026-08-07-workflow-editor-toast-and-publish-contract-design.md @@ -17,8 +17,11 @@ command, and observes the authoritative binding run. Workflow Activity vNext requires a separately saved draft and currently constructs explicit request confirmations without using the shared confirmation helper. -This design separates the immediate UI correction from the later publication -contract decision. +This design covers both the notification correction and the approved +publication model: users work only with Workflow resources, while each +Workflow owns one system-managed Team and one system-managed Member that remain +hidden from the product UI. Publication reuses the existing member binding-run +contract without changing backend endpoints. ## Semantic Mismatch @@ -53,17 +56,22 @@ This change will: toasts. 6. Preserve recovery actions such as `Retry` and `Check again` inside the corresponding toast or an existing command/status control. -7. Keep the current Publish submission and observation implementation unchanged - until the frontend and backend agree on the contract described below. +7. Replace Workflow Activity vNext's workflow publication submission with the + existing member binding-run publication orchestration. +8. Provision one hidden Team and one hidden workflow Member for every newly + created Workflow, using only existing Team and Member endpoints. +9. Keep the hidden resource identities explicit and recoverable through typed + member read-model fields. This change will not: -- switch Workflow Activity vNext to a member API; -- change the Publish endpoint or accepted response; -- add `publishedServiceId` to `ScopeWorkflowUpsertResult`; +- change any backend endpoint or response contract; +- expose Team or Member creation, selection, navigation, or terminology to the + Workflow user; +- derive `memberId`, `workflowId`, or `publishedServiceId` from one another; - change warning severity or make non-blocking warnings block Publish; - add a new service-selection dialog; -- claim that an accepted command is already published. +- claim that an accepted binding command is already published. ## Error And Warning Presentation @@ -98,178 +106,123 @@ top-right console notification surface. ## Correct Publish Product Flow -The following flow is the target contract for frontend/backend discussion. It -is not implemented by this change. +### Resource Model -### Resource Identities +The user creates and operates only a Workflow. Internally, every Workflow owns +one distinct system-managed Team and one distinct system-managed workflow +Member: -- `memberId` identifies Team Member authority. Only member endpoints accept it. -- `workflowId` identifies an editable workflow draft or definition. Workflow - draft and workflow publication endpoints accept it. -- `publishedServiceId` identifies the callable published service. Service - revision and invocation queries accept it. -- `revisionId` identifies one immutable publication attempt/artifact revision. -- `commandId` and `correlationId` trace a command; they are not resource IDs. - -No frontend code may derive one identity from another by equality, prefix, -route position, service key parsing, or naming convention. - -### 1. Prepare The Exact Draft - -When the user selects Publish: - -1. Apply or explicitly reject unapplied node-inspector changes. -2. Serialize the current document and validate the serialized result. -3. Block only on error-level findings. Emit warning findings as warning toasts. -4. If the document is dirty, save it through the workflow draft API. -5. Observe the exact saved draft/version before publishing, or let a single - backend command atomically accept the saved content and publication intent. +```text +Workflow A -> hidden Team A -> hidden Member A -> Published Service A +Workflow B -> hidden Team B -> hidden Member B -> Published Service B +``` -The published bytes must be the same bytes that were validated and reviewed. +The hidden resources are not shared between Workflows. They do not appear as +creation steps, selectors, navigation destinations, labels, or settings in the +Workflow product surface. -### 2. Review Explicit Requests +- `workflowId` identifies the editable draft or definition. +- `teamId` identifies the hidden Team owned by exactly one Workflow. +- `memberId` identifies the hidden Member and is the binding authority. +- `publishedServiceId` identifies the callable published service. +- `revisionId` identifies one immutable publication attempt. -The frontend generates a fresh opaque `revisionId` candidate and calls the -typed explicit-request preview endpoint with `scopeId`, `workflowId`, -`revisionId`, and the exact workflow YAML. +No frontend code may derive one identity from another by equality, prefix, +route position, service key parsing, or naming convention. Each ID comes from +its own existing API response. -- If there are no explicit requests, continue without a dialog. -- If explicit requests exist, reuse - `confirmInteractiveExplicitRequestPreview`. -- If the user cancels, return to idle without an error toast. -- The backend must verify confirmations against the same workflow and revision; - the frontend confirmation is not authority by itself. +### 1. Provision Hidden Authorities -### 3. Dispatch A Workflow Publish Command +Creating a Workflow remains one user command. The frontend orchestrates these +existing APIs behind that command: -Workflow Activity vNext dispatches a workflow publication command, not a member -binding command. A suggested request contract is: +1. Create the Workflow draft and receive `workflowId`. +2. Create a dedicated Team and receive `teamId`. +3. Create a workflow Member assigned to that Team and receive `memberId`. +4. Patch the Member's typed implementation reference with + `implementationKind = workflow` and the exact `workflowId`. +5. Open the Workflow editor without exposing Team or Member concepts. -```text -scopeId -workflowId -revisionId -workflowName -displayName -workflowYaml or savedDraftVersion -explicitRequestConfirmations[] -``` +The typed `implementationRef.workflowId` is the durable lookup relationship. +Reloading or entering from the Workflow list resolves the backing Member by +that field, never by an ID convention. Provisioning retries must reuse an +already-linked Member instead of creating a second hidden resource pair. -The response is an honest accepted receipt: +### 2. Prepare The Exact Draft -```text -acceptanceStage = accepted -scopeId -workflowId -revisionId -acceptedAtUtc -commandHandles[] { - stage - targetActorId - commandId - correlationId -} -readModelUrl -``` - -The accepted response means only that the command entered the target actor -inbox. It must not imply that the revision is committed, projected, serving, or -readable. +When the user selects Publish: -If the backend can authoritatively allocate `publishedServiceId` before -dispatch, it may return that opaque ID. The frontend must not require it in the -accepted receipt. The current `feature/integrate` contract does not return it. +1. Apply or explicitly reject unapplied node-inspector changes. +2. If the document is dirty, serialize, validate, and save it through the + workflow draft API. +3. Block only on error-level findings and emit warning findings as warning + toasts. +4. Publish the exact serialized bytes that were validated and saved. -### 4. Commit And Project Authoritative State +### 3. Review Explicit Requests -The workflow authority commits the publication state and emits the committed -fact into the standard projection pipeline. The projection materializes a typed -workflow publication read model containing at least: +Generate a fresh opaque `revisionId` and call the existing typed explicit +request preview endpoint with `scopeId`, `workflowId`, `revisionId`, and the +exact Workflow YAML. -```text -scopeId -workflowId -revisionId -publicationState -publishedServiceId -definitionActorId -failureCode/failureReason when terminally failed -authoritativeStateVersion -updatedAtUtc -``` +- Continue immediately when there are no explicit requests. +- Reuse `confirmInteractiveExplicitRequestPreview` when confirmation is needed. +- Treat cancellation as idle, without an error toast. -Recommended states are `accepted`, `preparing`, `published`, and `failed`. -`accepted` may remain command-side only if the read model starts at -`preparing`. +### 4. Dispatch The Existing Member Binding Command -The most direct API is either: +Resolve the Workflow's backing `memberId` and call: ```text -GET /api/scopes/:scopeId/workflows/:workflowId +PUT /api/scopes/:scopeId/members/:memberId/binding ``` -with a typed current publication sub-message, or: +The body carries the draft identity separately: ```text -GET /api/scopes/:scopeId/workflows/:workflowId/publications/:revisionId +revisionId +workflow.workflowId +workflow.workflowYamls[] +explicitRequestConfirmations[] ``` -backed by the same workflow-owned current-state projection. The query must not -prime projections or replay the event store. - -### 5. Observe Without Re-Publishing - -After receiving an accepted receipt, the frontend polls or subscribes to the -exact `scopeId + workflowId + revisionId` publication state. - -- `preparing`: show `Publishing` in the command/status control. -- `published`: store the typed `publishedServiceId`, show a success toast, and - enable run/invoke only when the serving revision is available. -- `failed`: show one error toast with the authoritative failure reason and allow - a new Publish attempt. -- observation timeout: show a warning toast and expose `Check status`; do not - submit another publish command. -- `401/403`: show an authorization error toast and require the corresponding - authentication or permission recovery. - -If the workflow read model exposes only `publishedServiceId`, the frontend may -then query the exact service revision catalog to confirm that `revisionId` is -active and serving. A typed publication state on the workflow read model is -preferred because it avoids making the frontend infer one business state from -two independently delayed query replicas. - -### 6. Idempotency And Retry - -- Before an accepted receipt exists, the same `revisionId` and idempotency key - may be retried according to the command contract. -- After an accepted receipt exists, `Check status` only re-observes; it never - dispatches a duplicate Publish command. -- A new Publish attempt uses a new `revisionId` only after the prior attempt is - terminally failed or the user has changed the saved document. -- Repeated projection writes are idempotent and older authoritative versions - cannot overwrite newer ones. - -## Why Team Member Publish Cannot Be Copied Literally +Workflow Activity vNext must not call +`PUT /api/scopes/:scopeId/workflows/:workflowId` for Publish. The accepted +member binding response means only that the binding run was accepted for +dispatch; it does not mean publication succeeded. -Team Member Studio publishes member authority by calling -`bindMemberWorkflow(scopeId, memberId, workflowId, ...)` and observing a member -binding run. Workflow Activity vNext edits a workflow resource and does not -have a canonical `memberId` in its route or draft contract. Calling the member -endpoint with `workflowId` would violate the repository identity rules. +### 5. Observe The Binding Run -The reusable part is the orchestration pattern: +Poll the exact run returned by the accepted receipt: ```text -save exact draft --> serialize and validate --> preview explicit requests --> obtain required confirmations --> dispatch the correct resource command --> observe authoritative status --> toast success/failure +GET /api/scopes/:scopeId/members/:memberId/binding-runs/:bindingRunId ``` -Only the resource-specific dispatch and observation adapters differ. +- Active states remain `accepted`, `admission_pending`, `admitted`, + `platform_binding_pending`, and `member_notification_pending`. +- `succeeded` triggers a member refetch, a success toast, and enables published + actions from the returned `publishedServiceId`. +- `failed` or `rejected` triggers one error toast with the authoritative reason. +- Observation delay triggers a warning toast and `Check status`; it never + dispatches another Publish command. + +### 6. Retry And Recovery + +- A refresh resolves the backing Member through typed + `implementationRef.workflowId`. +- An accepted run is only observed; it is not resubmitted. +- A terminally failed run may be followed by a new attempt with a new + `revisionId`. +- Partial hidden-resource provisioning is recoverable: retries reuse the Team + and Member identities already returned or materialized for the Workflow. + +### 7. Delete And Archive + +Deleting a Workflow must also clean up its one-to-one hidden Member and Team +through existing endpoints. Cleanup uses the explicit resolved IDs and never +constructs one ID from another. A failed cleanup is reported as a toast and is +retryable so the product does not silently leave unreachable hidden resources. ## Test Contract For The Immediate UI Change From 88c573a6dd7fd60186af4607981a6ca050baf67d Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:48:35 +0800 Subject: [PATCH 09/15] Plan hidden workflow publication --- ...8-07-hidden-workflow-member-publication.md | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-07-hidden-workflow-member-publication.md diff --git a/docs/superpowers/plans/2026-08-07-hidden-workflow-member-publication.md b/docs/superpowers/plans/2026-08-07-hidden-workflow-member-publication.md new file mode 100644 index 0000000000..522bcbda01 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-hidden-workflow-member-publication.md @@ -0,0 +1,377 @@ +# Hidden Workflow Member Publication 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:** Make Workflow Activity vNext provision one hidden Team and Member per Workflow and publish through the existing member binding-run contract without exposing Team or Member concepts to users. + +**Architecture:** A focused `workflowBackingAuthority` module owns hidden resource provisioning, typed relationship lookup, and cleanup. The Workflow creation and list surfaces call that module, while `useWorkflowPublication` observes member binding runs and `WorkflowEditorPage` reuses the existing explicit-request confirmation plus member binding APIs. The editor continues to expose only Workflow commands and toast notifications. + +**Tech Stack:** React, TypeScript, React Query, Jest, Testing Library, Ant Design, existing `studioApi` wrappers. + +--- + +### Task 1: Hidden Workflow Authority Module + +**Files:** +- Create: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.ts` +- Create: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts` +- Modify: `apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts` + +- [ ] **Step 1: Write failing identity lookup tests** + +Add tests that pass members with distinct IDs and assert that lookup matches only the typed workflow reference: + +```ts +expect( + resolveWorkflowBackingAuthority({ + workflowId: 'wf-alpha', + members: [ + member({ memberId: 'm-other', workflowId: 'wf-other' }), + member({ memberId: 'm-alpha', teamId: 't-alpha', workflowId: 'wf-alpha' }), + ], + }), +).toEqual({ memberId: 'm-alpha', teamId: 't-alpha' }); +``` + +Also assert that zero matches returns `null`, and two exact matches throw a duplicate-authority error rather than picking one. + +- [ ] **Step 2: Run the new test and verify RED** + +Run from `apps/aevatar-console-web`: + +```bash +pnpm exec jest src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts --runInBand +``` + +Expected: FAIL because `workflowBackingAuthority` does not exist. + +- [ ] **Step 3: Implement typed lookup** + +Create these public types and function: + +```ts +export type WorkflowBackingAuthority = { + readonly memberId: string; + readonly teamId: string; +}; + +export function resolveWorkflowBackingAuthority(input: { + readonly members: readonly StudioMemberSummary[]; + readonly workflowId: string; +}): WorkflowBackingAuthority | null; +``` + +Filter only members where `implementationKind === 'workflow'`, `implementationRef?.implementationKind === 'workflow'`, and `implementationRef.workflowId === workflowId`. Require nonblank `memberId` and `teamId`. Never compare or construct identities by prefix. + +- [ ] **Step 4: Verify typed lookup GREEN** + +Run the Task 1 Jest command and expect all lookup tests to pass. + +- [ ] **Step 5: Write failing provisioning tests** + +Add injected dependency tests for: + +```ts +await provisionWorkflowBackingAuthority({ + scopeId: 'scope-alpha', + workflowId: 'wf-alpha', + workflowName: 'Approval flow', + api, + wait: async () => undefined, +}); +``` + +Assert the exact sequence: + +```text +createTeam -> getTeam until readable -> createMember(teamId) +-> getMember until readable -> updateMemberImplementationRef(workflowId) +-> getMember until the typed link is readable +``` + +Use distinct fixtures `t-alpha`, `m-alpha`, `wf-alpha`, and `svc-alpha`. Add a test proving that an already-linked member is reused without create calls, and a test proving 404 materialization delays are retried without inventing another identity. + +- [ ] **Step 6: Verify provisioning tests RED** + +Run the Task 1 Jest command. Expected: FAIL because the provisioning API is missing. + +- [ ] **Step 7: Implement provisioning and reuse the existing member-link wait behavior** + +Define a narrow injected API contract using existing methods: + +```ts +type WorkflowBackingAuthorityApi = Pick< + typeof studioApi, + | 'createMember' + | 'createTeam' + | 'getMember' + | 'getTeam' + | 'listMembers' + | 'updateMemberImplementationRef' +>; +``` + +Export `provisionWorkflowBackingAuthority`, `waitForWorkflowMemberVisible`, and `linkWorkflowMemberDraft`. Move the equivalent `waitForCreatedMemberVisible` and `linkCreatedWorkflowMemberDraft` logic out of the Team Member hook and call the shared functions there, preserving its current retry behavior. + +- [ ] **Step 8: Verify provisioning GREEN** + +Run: + +```bash +pnpm exec jest src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts src/pages/team-member-workflow-studio/index.test.tsx --runInBand +``` + +Expected: all related authority and existing Team Member tests pass. + +### Task 2: Provision One Hidden Pair During Workflow Creation + +**Files:** +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.tsx` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.test.tsx` + +- [ ] **Step 1: Write a failing user-flow test** + +Extend the Studio API mock with Team and Member methods. Create a materialized draft fixture and assert that one user creation command produces exactly one dedicated Team and one dedicated Member before navigation: + +```ts +expect(mockStudioApi.createTeam).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + displayName: 'Approval flow', + description: expect.any(String), +}); +expect(mockStudioApi.createMember).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + displayName: 'Approval flow', + implementationKind: 'workflow', + teamId: 't-alpha', +}); +expect(mockStudioApi.updateMemberImplementationRef).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + memberId: 'm-alpha', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-alpha', + }, +}); +``` + +Assert no Team/Member label, selector, link, or success copy is rendered. + +- [ ] **Step 2: Verify creation test RED** + +Run: + +```bash +pnpm exec jest src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.test.tsx --runInBand +``` + +Expected: FAIL because creation navigates after draft materialization without provisioning authorities. + +- [ ] **Step 3: Implement hidden provisioning before navigation** + +Change `finishSave` so both materialized and accepted-then-readable drafts call: + +```ts +await provisionWorkflowBackingAuthority({ + api: studioApi, + scopeId, + workflowId: readable.workflowId, + workflowName: readable.name, +}); +navigateToWorkflow(readable.workflowId); +``` + +Keep the existing Workflow-only loading and failure presentation. Do not add Team or Member state to route/query parameters or visible text. + +- [ ] **Step 4: Add a failing partial-provision failure test** + +Make `updateMemberImplementationRef` fail and assert navigation does not occur, the existing Workflow creation error toast appears, and retrying uses the already-linked roster result rather than creating a second pair. + +- [ ] **Step 5: Implement retry-safe resolution** + +Before creating resources, call `listMembers(scopeId)` and reuse an exact typed match. Keep the creation form input intact when provisioning fails so the existing create action can be retried. + +- [ ] **Step 6: Verify creation GREEN** + +Run the Task 2 Jest command and expect all New Workflow tests to pass. + +### Task 3: Publish Through Member Binding Runs + +**Files:** +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowEditor.ts` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx` + +- [ ] **Step 1: Replace publication observer tests with binding-run expectations** + +Define the receipt with distinct identities: + +```ts +const receipt = { + scopeId: 'scope-alpha', + workflowId: 'wf-alpha', + memberId: 'm-alpha', + bindingRunId: 'bind-alpha', + revisionId: 'rev-alpha', +}; +``` + +Test active states, 404 projection delay, `succeeded` with matching `result.revisionId`, terminal failure/rejection, mismatched scope/member/run identity, and delayed observation without resubmission. + +- [ ] **Step 2: Verify observer RED** + +Run: + +```bash +pnpm exec jest src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts --runInBand +``` + +Expected: FAIL because the current observer reads workflow/service revision catalogs. + +- [ ] **Step 3: Implement binding-run observation** + +Replace workflow/service reads with `studioApi.getMemberBindingRun`. Preserve a receipt-bound React Query key. Return `publishedServiceId` only from a `succeeded` run whose scope, member, run, and revision all match the accepted receipt. Map exhausted polling to `delayed`, not `failed`. + +- [ ] **Step 4: Verify observer GREEN** + +Run the Task 3 observer command and expect all tests to pass. + +- [ ] **Step 5: Write failing editor integration tests** + +In `index.test.tsx`, mock `bindMemberWorkflow`, `getMemberBindingRun`, and `confirmInteractiveExplicitRequestPreview`. Assert Publish: + +1. Resolves `m-alpha` from `implementationRef.workflowId === 'wf-alpha'`. +2. Saves dirty content before preview. +3. Calls the shared explicit-request confirmation helper. +4. Calls `bindMemberWorkflow` with separate member and workflow IDs. +5. Never calls `publishWorkflow`. +6. Treats cancellation as idle without an error toast. +7. Exposes `Check status` after delayed observation without a second bind call. + +- [ ] **Step 6: Verify editor integration RED** + +Run: + +```bash +pnpm exec jest src/pages/workflow-activity-vnext/index.test.tsx --runInBand +``` + +Expected: FAIL on the current workflow publication endpoint and fabricated confirmations. + +- [ ] **Step 7: Make publication preparation save dirty drafts** + +Refactor the editor hook's save core to return the exact readable `StudioWorkflowFile`. Make `preparePublication` invoke that core when dirty, then serialize the saved document and return its exact YAML, `workflowId`, name, and document version. Keep the standalone Save button behavior unchanged. + +- [ ] **Step 8: Bind the resolved Member** + +Add a React Query member roster query and call `resolveWorkflowBackingAuthority`. In `publishWorkflow`: + +```ts +const confirmations = await confirmInteractiveExplicitRequestPreview(preview); +if (confirmations === null) return; +const accepted = await studioApi.bindMemberWorkflow({ + scopeId: activeScopeId, + memberId: authority.memberId, + workflowId: preparation.workflowId, + revisionId: preview.revisionId, + workflowYamls: [preparation.workflowYaml], + explicitRequestConfirmations: confirmations, + displayName: preparation.workflowName, +}); +``` + +Store the accepted binding receipt for `useWorkflowPublication`; remove `studioApi.publishWorkflow` from this path. + +- [ ] **Step 9: Verify editor integration GREEN** + +Run the Task 3 observer and editor commands and expect all tests to pass. + +### Task 4: Toast-Only Workflow Editor Errors And Warnings + +**Files:** +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx` +- Modify: `apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts` +- Modify: `apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts` + +- [ ] **Step 1: Write failing presentation tests** + +Assert publication failures, save/materialization failures, error findings, and warning findings do not render page-level Ant Design alerts. Assert `toast.error` or `toast.warning` receives a stable notification and delayed publication retains a `Check status` action in the command/status area. + +- [ ] **Step 2: Verify presentation RED** + +Run the focused `index.test.tsx` command. Expected: FAIL because publication and finding alerts still render in page flow. + +- [ ] **Step 3: Implement toast-only presentation** + +Remove only the editor's publication, save-materialization, and finding alert bands. Deduplicate findings by `level + code + path + message` in an effect and route errors/warnings to the shared toast. Preserve toolbar status, retry, and `Check status` controls. Do not remove draft run result/error panels because those are run output, not transient editor notifications. + +- [ ] **Step 4: Verify presentation GREEN** + +Run the focused `index.test.tsx` command and expect all presentation tests to pass. + +### Task 5: Clean Hidden Resources When Deleting A Draft + +**Files:** +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.ts` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowsPage.tsx` +- Modify: `apps/aevatar-console-web/src/pages/workflow-activity-vnext/index.test.tsx` + +- [ ] **Step 1: Write failing cleanup tests** + +Test that cleanup resolves the exact typed member, then calls: + +```text +deleteMember({ scopeId, memberId }) +archiveTeam(scopeId, teamId) +deleteWorkflowDraft(workflowId, scopeId) +``` + +Treat member/team 404 as already cleaned. Assert another Workflow's Team/Member are untouched. + +- [ ] **Step 2: Verify cleanup RED** + +Run the backing authority and page test files. Expected: FAIL because draft deletion currently removes only the draft. + +- [ ] **Step 3: Implement explicit cleanup** + +Add `cleanupWorkflowBackingAuthority` using only IDs returned by `resolveWorkflowBackingAuthority`. Wire the existing draft delete confirmation through it, preserving the current retry state and toast wording at the Workflow level. + +- [ ] **Step 4: Verify cleanup GREEN** + +Run the Task 5 test command and expect all cleanup and page tests to pass. + +### Task 6: Focused Validation And PR Delivery + +**Files:** +- Review all files changed by Tasks 1-5. + +- [ ] **Step 1: Run dependency-related Jest tests** + +Run from `apps/aevatar-console-web`, passing only the changed source files to `--findRelatedTests`, then explicitly run every changed test file: + +```bash +pnpm exec jest --findRelatedTests src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.ts src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.tsx src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts src/pages/workflow-activity-vnext/hooks/useWorkflowEditor.ts src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx src/pages/workflow-activity-vnext/workflows/WorkflowsPage.tsx src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts --runInBand +pnpm exec jest src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.test.tsx src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts src/pages/workflow-activity-vnext/index.test.tsx src/pages/team-member-workflow-studio/index.test.tsx --runInBand +``` + +- [ ] **Step 2: Run changed-file static checks** + +Run Biome only on changed frontend files, using explicit paths. Do not run package-wide lint, `tsc`, or production build. Run `bash tools/ci/test_stability_guards.sh` because tests changed. + +- [ ] **Step 3: Review the complete diff** + +Confirm the diff contains no backend changes, no new Team/Member UI text, no identity derivation, no `publishWorkflow` call in Workflow Activity vNext, and no page-level editor error/warning alert bands. + +- [ ] **Step 4: Commit and update the PR** + +Stage only this task's files, commit with an imperative message, force-push with lease because the branch was rebased, and update PR #3276 with exact focused commands and: + +```text +Full frontend suite/build: deferred to GitHub CI by personal local workflow policy +``` + +Do not wait for CI unless requested. From 12c42076f73dbc327901cf6700fdd5d198f236c3 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:52:42 +0800 Subject: [PATCH 10/15] Reuse member binding runs for workflow publication --- .../hooks/useTeamMemberWorkflowStudio.ts | 75 +- .../hooks/useWorkflowEditor.ts | 56 +- .../hooks/useWorkflowPublication.test.ts | 410 ++------ .../hooks/useWorkflowPublication.ts | 220 +--- .../workflow-activity-vnext/index.test.tsx | 953 +++++++++++------- .../workflows/NewWorkflowPage.test.tsx | 138 +++ .../workflows/NewWorkflowPage.tsx | 22 +- .../workflows/WorkflowEditorPage.tsx | 369 +++---- .../workflows/WorkflowsPage.tsx | 33 +- .../workflowBackingAuthority.test.ts | 330 ++++++ .../shared/studio/workflowBackingAuthority.ts | 311 ++++++ 11 files changed, 1798 insertions(+), 1119 deletions(-) create mode 100644 apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts create mode 100644 apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts index 7173c852d4..2b458868af 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts @@ -68,6 +68,7 @@ import type { StudioWorkflowSaveResult, } from '@/shared/studio/models'; import { normalizeStudioMemberLifecycleStage } from '@/shared/studio/models'; +import { linkWorkflowMemberDraft } from '@/shared/studio/workflowBackingAuthority'; import { useConsoleToast } from '@/shared/ui/ConsoleToast'; type TeamMemberWorkflowStudioMode = 'new' | 'existing'; @@ -303,8 +304,6 @@ const AVAILABLE_STEP_TYPES = STUDIO_GRAPH_CATEGORIES.flatMap( ); const MEMBER_BINDING_RUN_POLL_ATTEMPTS = 8; const MEMBER_BINDING_RUN_POLL_DELAY_MS = 900; -const CREATED_MEMBER_MATERIALIZATION_ATTEMPTS = 8; -const CREATED_MEMBER_MATERIALIZATION_DELAY_MS = 450; const WORKFLOW_DRAFT_MATERIALIZATION_ATTEMPTS = 10; const WORKFLOW_DRAFT_MATERIALIZATION_DELAY_MS = 900; const SAVE_AND_BIND_WORKFLOW_MATERIALIZATION_ATTEMPTS = 12; @@ -586,17 +585,6 @@ function waitForBindingRunPollTick(): Promise { }); } -function waitForCreatedMemberMaterializationTick(): Promise { - return new Promise((resolve) => { - const testEnvironment = - typeof process !== 'undefined' && process.env.NODE_ENV === 'test'; - window.setTimeout( - resolve, - testEnvironment ? 0 : CREATED_MEMBER_MATERIALIZATION_DELAY_MS, - ); - }); -} - function waitForWorkflowDraftMaterializationTick(): Promise { return new Promise((resolve) => { const testEnvironment = @@ -682,34 +670,6 @@ async function loadPublishedWorkflowWithDraftFallback(input: { } } -async function waitForCreatedMemberVisible(input: { - readonly memberId: string; - readonly scopeId: string; -}): Promise { - let lastNotFound: unknown = null; - - for ( - let attempt = 0; - attempt < CREATED_MEMBER_MATERIALIZATION_ATTEMPTS; - attempt += 1 - ) { - try { - return await studioApi.getMember(input.scopeId, input.memberId); - } catch (error) { - if (!isStudioApiStatus(error, 404)) { - throw error; - } - - lastNotFound = error; - if (attempt < CREATED_MEMBER_MATERIALIZATION_ATTEMPTS - 1) { - await waitForCreatedMemberMaterializationTick(); - } - } - } - - throw new CreatedWorkflowMemberLinkPendingError(lastNotFound); -} - async function waitForWorkflowDraftMaterialized(input: { readonly receipt: StudioWorkflowDraftCreateAcceptedReceipt; readonly scopeId: string; @@ -754,38 +714,7 @@ async function linkCreatedWorkflowMemberDraft(input: { readonly workflowId: string; }): Promise { try { - await waitForCreatedMemberVisible({ - memberId: input.memberId, - scopeId: input.scopeId, - }); - - try { - await studioApi.updateMemberImplementationRef({ - scopeId: input.scopeId, - memberId: input.memberId, - implementationRef: { - implementationKind: 'workflow', - workflowId: input.workflowId, - }, - }); - } catch (error) { - if (!isStudioApiStatus(error, 404)) { - throw error; - } - - await waitForCreatedMemberVisible({ - memberId: input.memberId, - scopeId: input.scopeId, - }); - await studioApi.updateMemberImplementationRef({ - scopeId: input.scopeId, - memberId: input.memberId, - implementationRef: { - implementationKind: 'workflow', - workflowId: input.workflowId, - }, - }); - } + await linkWorkflowMemberDraft({ ...input, api: studioApi }); } catch (error) { if (error instanceof CreatedWorkflowMemberLinkPendingError) { throw error; 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 index 98839a82b0..789202e8fe 100644 --- 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 @@ -409,7 +409,7 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { return true; }, [adoptReadableWorkflow, document, materialization.retry]); - const save = React.useCallback(async () => { + const saveReadableWorkflow = React.useCallback(async () => { const followsCanonicalRouteReplacement = pendingRouteWorkflowIdRef.current === workflow?.workflowId; if ( @@ -421,20 +421,20 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { structuralMutationPendingRef.current || !workflowTitle.trim() ) - return false; + return null; savingRef.current = true; setSaving(true); setValidating(true); setSaveError(''); try { const parsedDocument = await parseCurrentYaml(); - if (!parsedDocument) return false; + if (!parsedDocument) return null; const serialized = await studioApi.serializeYaml({ document: parsedDocument, }); setFindings(serialized.findings); if (hasBlockingFindings(serialized.document, serialized.findings)) - return false; + return null; setValidating(false); const directoryId = workflow.directoryId || @@ -465,23 +465,23 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { submittedSnapshot.document, submittedSnapshot.revision, ); - return true; + return result.workflow; } pendingMaterializationRef.current = submittedSnapshot; savingRef.current = false; setSaving(false); const saved = await materialization.observe(result.receipt); - if (!saved) return false; + if (!saved) return null; pendingMaterializationRef.current = null; adoptReadableWorkflow( saved, submittedSnapshot.document, submittedSnapshot.revision, ); - return true; + return saved; } catch (error) { setSaveError(toErrorMessage(error)); - return false; + return null; } finally { savingRef.current = false; setSaving(false); @@ -494,13 +494,17 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { parseCurrentYaml, receiptPending, routeWorkflowId, - saving, scopeId, workflow, workflowTitle, workspace.data, ]); + const save = React.useCallback( + async () => (await saveReadableWorkflow()) !== null, + [saveReadableWorkflow], + ); + const addNode = React.useCallback( async (stepType: string): Promise => { if (savingRef.current || structuralMutationPendingRef.current) @@ -747,7 +751,6 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { !workflow.draftExists || (workflow.workflowId !== routeWorkflowId && !followsCanonicalRouteReplacement) || - dirty || savingRef.current || receiptPending || structuralMutationPendingRef.current || @@ -758,7 +761,23 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { ); } - const parsed = await studioApi.parseYaml({ yaml }); + const publicationDocumentVersion = localEditRevisionRef.current; + const readableWorkflow = dirty ? await saveReadableWorkflow() : workflow; + if (!readableWorkflow) { + throw new Error('This workflow could not be saved for publication.'); + } + if (localEditRevisionRef.current !== publicationDocumentVersion) { + throw new Error( + 'This workflow changed while publication was being prepared.', + ); + } + + const parsed = readableWorkflow.document + ? { + document: readableWorkflow.document, + findings: readableWorkflow.findings, + } + : await studioApi.parseYaml({ yaml: readableWorkflow.yaml }); setFindings(parsed.findings); const parsedDocument = parsed.document ?? null; if ( @@ -781,12 +800,19 @@ export function useWorkflowEditor(scopeId: string, routeWorkflowId: string) { } return { - documentVersion: localEditRevisionRef.current, - workflowId: workflow.workflowId, - workflowName: workflowTitle.trim(), + documentVersion: publicationDocumentVersion, + workflowId: readableWorkflow.workflowId, + workflowName: readableWorkflow.name.trim(), workflowYaml: serialized.yaml, }; - }, [dirty, receiptPending, routeWorkflowId, workflow, workflowTitle, yaml]); + }, [ + dirty, + receiptPending, + routeWorkflowId, + saveReadableWorkflow, + workflow, + workflowTitle, + ]); const submitRun = React.useCallback( async ( diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts index 988efada53..8a2c956adc 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.test.ts @@ -1,6 +1,4 @@ -import type { ScopeServiceRevisionCatalogSnapshot } from '@/shared/models/runtime/scopeServices'; -import type { ScopeWorkflowDetail } from '@/shared/models/scopes'; -import type { StudioScopeBindingRevision } from '@/shared/studio/models'; +import type { StudioMemberBindingRunStatusResponse } from '@/shared/studio/models'; import { observeWorkflowPublication, resolveWorkflowPublicationPhase, @@ -9,357 +7,166 @@ import { const receipt: WorkflowPublicationReceipt = { scopeId: 'scope-alpha', - workflowId: 'wf-publication-alpha', - revisionId: 'rev-publication-alpha', + workflowId: 'wf-alpha', + memberId: 'm-alpha', + bindingRunId: 'bind-alpha', + revisionId: 'rev-alpha', }; -const publishedServiceId = 'svc-publication-alpha'; -function workflowDetail( - scopeId = receipt.scopeId, - workflowId = receipt.workflowId, - actorId = 'actor-workflow-publication', -): ScopeWorkflowDetail & { - readonly workflow: NonNullable; -} { - return { - available: true, - scopeId, - workflow: { - scopeId, - workflowId, - publishedServiceId: receipt.publishedServiceId, - displayName: 'Publication workflow', - serviceKey: 'workflow-publication', - workflowName: 'Publication workflow', - actorId, - activeRevisionId: receipt.revisionId, - deploymentId: 'deployment-workflow-alpha', - deploymentStatus: 'Available', - serviceAppId: 'studio', - serviceNamespace: 'workflow-publications', - publishedServiceId, - updatedAt: '2026-08-06T10:00:00Z', - }, - source: null, - }; -} - -function serviceRevision( - changes: Partial = {}, -): StudioScopeBindingRevision { - return { - revisionId: receipt.revisionId, - implementationKind: 'workflow', - status: 'Published', - artifactHash: 'artifact-publication-alpha', - failureReason: '', - isDefaultServing: false, - isActiveServing: true, - isServingTarget: true, - allocationWeight: 100, - servingState: 'Active', - deploymentId: 'deployment-service-alpha', - primaryActorId: 'actor-service-alpha', - createdAt: '2026-08-06T10:00:00Z', - preparedAt: '2026-08-06T10:00:01Z', - publishedAt: '2026-08-06T10:00:02Z', - retiredAt: null, - workflowName: 'Publication workflow', - workflowDefinitionActorId: 'actor-workflow-publication', - inlineWorkflowCount: 0, - scriptId: '', - scriptRevision: '', - scriptDefinitionActorId: '', - scriptSourceHash: '', - staticActorTypeName: '', - ...changes, - }; -} - -function revisionCatalog( - revisions: readonly StudioScopeBindingRevision[], - changes: Partial = {}, -): ScopeServiceRevisionCatalogSnapshot { +function bindingRun( + changes: Partial = {}, +): StudioMemberBindingRunStatusResponse { return { + status: 'accepted', + bindingRunId: receipt.bindingRunId, scopeId: receipt.scopeId, - serviceId: publishedServiceId, - serviceKey: 'service-publication', - displayName: 'Publication service', - defaultServingRevisionId: receipt.revisionId, - activeServingRevisionId: receipt.revisionId, - deploymentId: 'deployment-service-alpha', - deploymentStatus: 'Active', - primaryActorId: 'actor-service-alpha', - catalogStateVersion: 12, - catalogLastEventId: 'evt-service-alpha', - updatedAt: '2026-08-06T10:00:03Z', - revisions, + memberId: receipt.memberId, + stateVersion: 7, + platformBindingCommandId: 'command-alpha', + result: null, + failure: null, + updatedAt: '2026-08-07T10:00:00Z', ...changes, }; } -function httpStatusError( - status: number, - code?: string, -): Error & { readonly status: number; readonly code?: string } { - return Object.assign(new Error(`HTTP ${status}`), { status, code }); +function httpStatusError(status: number): Error & { readonly status: number } { + return Object.assign(new Error(`HTTP ${status}`), { status }); } describe('observeWorkflowPublication', () => { - it('observes only the accepted workflow, service, and revision once its active serving revision is published', async () => { - let resolveWorkflow: (detail: ScopeWorkflowDetail) => void = () => - undefined; - const workflowRead = jest.fn( - () => - new Promise((resolve) => { - resolveWorkflow = resolve; - }), - ); - const revisionsRead = jest - .fn() - .mockResolvedValue(revisionCatalog([serviceRevision()])); - - const observation = observeWorkflowPublication({ - receipt, - readWorkflow: workflowRead, - readRevisions: revisionsRead, - wait: async () => undefined, - delaysMs: [0], - }); - - await Promise.resolve(); - expect(revisionsRead).not.toHaveBeenCalled(); - resolveWorkflow(workflowDetail()); + it.each([ + 'accepted', + 'admission_pending', + 'admitted', + 'platform_binding_pending', + 'member_notification_pending', + ] as const)('keeps observing the accepted binding run while it is %s', async (status) => { + const readBindingRun = jest.fn().mockResolvedValue(bindingRun({ status })); - await expect(observation).resolves.toMatchObject({ - kind: 'observed', - publishedServiceId, - revision: { revisionId: 'rev-publication-alpha' }, - }); - expect(revisionsRead).toHaveBeenCalledWith( - 'scope-alpha', - 'svc-publication-alpha', - ); - expect(workflowRead).toHaveBeenCalledWith( + await expect( + observeWorkflowPublication({ + receipt, + readBindingRun, + wait: async () => undefined, + delaysMs: [0], + }), + ).resolves.toEqual({ kind: 'delayed' }); + expect(readBindingRun).toHaveBeenCalledWith( 'scope-alpha', - 'wf-publication-alpha', + 'm-alpha', + 'bind-alpha', ); }); - it('recognizes case, whitespace, underscore, and hyphen variants in the published serving evidence', async () => { + it('observes publishedServiceId only from a matching succeeded binding run', async () => { await expect( observeWorkflowPublication({ receipt, - readWorkflow: async () => workflowDetail(), - readRevisions: async () => - revisionCatalog([ - serviceRevision({ - status: ' PUB_lished ', - servingState: ' a-c_t i-v_e ', - }), - ]), + readBindingRun: async () => + bindingRun({ + status: 'succeeded', + result: { + publishedServiceId: 'svc-alpha', + revisionId: 'rev-alpha', + implementationKind: 'workflow', + expectedActorId: 'actor-alpha', + }, + }), wait: async () => undefined, delaysMs: [0], }), - ).resolves.toMatchObject({ kind: 'observed' }); + ).resolves.toEqual({ + kind: 'observed', + publishedServiceId: 'svc-alpha', + run: expect.objectContaining({ + status: 'succeeded', + bindingRunId: 'bind-alpha', + }), + }); }); - it('treats receipt-bound workflow 404 and 409 plus a missing revision as a delayed observation without another publish request', async () => { - const workflowRead = jest - .fn() - .mockRejectedValueOnce(httpStatusError(404)) - .mockRejectedValueOnce(httpStatusError(409, 'USER_WORKFLOW_NOT_READY')); - const revisionsRead = jest.fn().mockResolvedValue(revisionCatalog([])); + it('treats binding-run projection 404 as delayed observation without resubmission', async () => { + const readBindingRun = jest.fn().mockRejectedValue(httpStatusError(404)); await expect( observeWorkflowPublication({ receipt, - readWorkflow: workflowRead, - readRevisions: revisionsRead, + readBindingRun, wait: async () => undefined, delaysMs: [0, 0], }), ).resolves.toEqual({ kind: 'delayed' }); - expect(workflowRead.mock.calls).toEqual([ - ['scope-alpha', 'wf-publication-alpha'], - ['scope-alpha', 'wf-publication-alpha'], - ]); - expect(revisionsRead).not.toHaveBeenCalled(); + expect(readBindingRun).toHaveBeenCalledTimes(2); }); - it('treats a receipt-bound service revision catalog 404 as observation delay', async () => { - const revisionsRead = jest.fn().mockRejectedValue(httpStatusError(404)); - + it.each([ + ['scope', { scopeId: 'scope-other' }], + ['member', { memberId: 'm-other' }], + ['run', { bindingRunId: 'bind-other' }], + ])('rejects a binding run with mismatched %s identity', async (_, changes) => { await expect( observeWorkflowPublication({ receipt, - readWorkflow: async () => workflowDetail(), - readRevisions: revisionsRead, + readBindingRun: async () => bindingRun(changes), wait: async () => undefined, delaysMs: [0], }), - ).resolves.toEqual({ kind: 'delayed' }); - expect(revisionsRead).toHaveBeenCalledWith( - 'scope-alpha', - 'svc-publication-alpha', - ); + ).rejects.toThrow('does not match'); }); - it('waits for the workflow read model to expose its published service identity', async () => { - const detail = workflowDetail(); - const revisionsRead = jest.fn(); - + it('rejects a succeeded run for a different revision', async () => { await expect( observeWorkflowPublication({ receipt, - readWorkflow: async () => ({ - ...detail, - workflow: { - ...detail.workflow, - publishedServiceId: '', - }, - }), - readRevisions: revisionsRead, + readBindingRun: async () => + bindingRun({ + status: 'succeeded', + result: { + publishedServiceId: 'svc-alpha', + revisionId: 'rev-other', + implementationKind: 'workflow', + }, + }), wait: async () => undefined, delaysMs: [0], }), - ).resolves.toEqual({ kind: 'delayed' }); - expect(revisionsRead).not.toHaveBeenCalled(); - }); - - it('delays only the recognized workflow projection conflict codes', async () => { - for (const code of ['USER_WORKFLOW_NOT_READY', 'USER_WORKFLOW_STALE']) { - await expect( - observeWorkflowPublication({ - receipt, - readWorkflow: async () => { - throw httpStatusError(409, code); - }, - readRevisions: async () => revisionCatalog([serviceRevision()]), - wait: async () => undefined, - delaysMs: [0], - }), - ).resolves.toEqual({ kind: 'delayed' }); - } - }); - - it('fails instead of retrying bare and unrelated workflow conflicts', async () => { - for (const error of [ - httpStatusError(409), - httpStatusError(409, 'USER_WORKFLOW_CONFLICT'), - ]) { - await expect( - observeWorkflowPublication({ - receipt, - readWorkflow: async () => { - throw error; - }, - readRevisions: async () => revisionCatalog([serviceRevision()]), - wait: async () => undefined, - delaysMs: [0, 0], - }), - ).rejects.toBe(error); - } - }); - - it('fails fulfilled workflow and catalog identity mismatches', async () => { - const acceptedWorkflow = workflowDetail(); - const cases = [ - { - workflow: workflowDetail('scope-other', 'wf-other'), - catalog: revisionCatalog([serviceRevision()]), - expectedRevisionCalls: 0, - }, - { - workflow: { - ...acceptedWorkflow, - workflow: { - ...acceptedWorkflow.workflow, - scopeId: 'scope-other', - }, - }, - catalog: revisionCatalog([serviceRevision()]), - expectedRevisionCalls: 0, - }, - { - workflow: workflowDetail(), - catalog: revisionCatalog([serviceRevision()], { - scopeId: 'scope-other', - serviceId: 'svc-other', - }), - expectedRevisionCalls: 1, - }, - ]; - - for (const candidate of cases) { - const workflowRead = jest.fn().mockResolvedValue(candidate.workflow); - const revisionsRead = jest.fn().mockResolvedValue(candidate.catalog); - - await expect( - observeWorkflowPublication({ - receipt, - readWorkflow: workflowRead, - readRevisions: revisionsRead, - wait: async () => undefined, - delaysMs: [0, 0], - }), - ).rejects.toThrow('does not match'); - expect(workflowRead).toHaveBeenCalledTimes(1); - expect(revisionsRead).toHaveBeenCalledTimes( - candidate.expectedRevisionCalls, - ); - } + ).rejects.toThrow('revision'); }); - it('keeps observing when the exact revision exists but a prior revision remains active', async () => { - const workflowRead = jest.fn().mockResolvedValue(workflowDetail()); - const revisionsRead = jest.fn().mockResolvedValue( - revisionCatalog([serviceRevision()], { - activeServingRevisionId: 'rev-prior-alpha', - }), - ); - + it.each([ + 'failed', + 'rejected', + ] as const)('stops when the accepted binding run is %s', async (status) => { await expect( observeWorkflowPublication({ receipt, - readWorkflow: workflowRead, - readRevisions: revisionsRead, + readBindingRun: async () => + bindingRun({ + status, + failure: { + code: 'BINDING_FAILED', + message: 'Platform binding failed.', + }, + }), wait: async () => undefined, - delaysMs: [0], + delaysMs: [0, 0], }), - ).resolves.toEqual({ kind: 'delayed' }); - expect(workflowRead).toHaveBeenCalledTimes(1); - expect(revisionsRead).toHaveBeenCalledTimes(1); + ).rejects.toThrow('Platform binding failed.'); }); - it('fails when the exact serving revision implements a different workflow definition', async () => { - const workflowRead = jest - .fn() - .mockResolvedValue( - workflowDetail( - receipt.scopeId, - receipt.workflowId, - 'actor-workflow-other', - ), - ); - const revisionsRead = jest - .fn() - .mockResolvedValue(revisionCatalog([serviceRevision()])); - + it('fails a malformed succeeded response instead of guessing a service identity', async () => { await expect( observeWorkflowPublication({ receipt, - readWorkflow: workflowRead, - readRevisions: revisionsRead, + readBindingRun: async () => bindingRun({ status: 'succeeded' }), wait: async () => undefined, - delaysMs: [0, 0], + delaysMs: [0], }), - ).rejects.toThrow('does not implement'); - expect(workflowRead).toHaveBeenCalledTimes(1); - expect(revisionsRead).toHaveBeenCalledTimes(1); + ).rejects.toThrow('published service'); }); - it('maps authorization errors from an exact observation read to distinct phases', () => { + it('maps authorization errors from the binding-run read to distinct phases', () => { expect( resolveWorkflowPublicationPhase({ data: undefined, @@ -379,27 +186,4 @@ describe('observeWorkflowPublication', () => { }), ).toBe('forbidden'); }); - - it.each([ - ['PreparationFailed', serviceRevision({ status: 'preparation_failed' })], - ['failure reason', serviceRevision({ failureReason: 'artifact rejected' })], - ['Retired', serviceRevision({ status: ' RETIRED ' })], - ])('stops when the accepted revision reaches terminal %s state', async (_, revision) => { - const workflowRead = jest.fn().mockResolvedValue(workflowDetail()); - const revisionsRead = jest - .fn() - .mockResolvedValue(revisionCatalog([revision])); - - await expect( - observeWorkflowPublication({ - receipt, - readWorkflow: workflowRead, - readRevisions: revisionsRead, - wait: async () => undefined, - delaysMs: [0, 0], - }), - ).rejects.toThrow('terminal'); - expect(workflowRead).toHaveBeenCalledTimes(1); - expect(revisionsRead).toHaveBeenCalledTimes(1); - }); }); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts index dba2a09d3f..9510405137 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/hooks/useWorkflowPublication.ts @@ -1,36 +1,35 @@ import { useQuery } from '@tanstack/react-query'; import React from 'react'; -import { scopeRuntimeApi } from '@/shared/api/scopeRuntimeApi'; -import { scopesApi } from '@/shared/api/scopesApi'; -import type { ScopeServiceRevisionCatalogSnapshot } from '@/shared/models/runtime/scopeServices'; -import type { ScopeWorkflowDetail } from '@/shared/models/scopes'; -import type { StudioScopeBindingRevision } from '@/shared/studio/models'; +import { studioApi } from '@/shared/studio/api'; +import type { StudioMemberBindingRunStatusResponse } from '@/shared/studio/models'; export const WORKFLOW_PUBLICATION_OBSERVATION_DELAYS_MS = [ 0, 300, 700, 1200, 2000, ] as const; -const DELAYED_WORKFLOW_CONFLICT_CODES = new Set([ - 'USER_WORKFLOW_NOT_READY', - 'USER_WORKFLOW_STALE', +const ACTIVE_BINDING_RUN_STATUSES = new Set([ + 'accepted', + 'admission_pending', + 'admitted', + 'platform_binding_pending', + 'member_notification_pending', ]); export type WorkflowPublicationReceipt = { readonly scopeId: string; readonly workflowId: string; + readonly memberId: string; + readonly bindingRunId: string; readonly revisionId: string; }; export type WorkflowPublicationObservationInput = { readonly delaysMs?: readonly number[]; - readonly readRevisions: ( + readonly readBindingRun: ( scopeId: string, - serviceId: string, - ) => Promise; - readonly readWorkflow: ( - scopeId: string, - workflowId: string, - ) => Promise; + memberId: string, + bindingRunId: string, + ) => Promise; readonly receipt: WorkflowPublicationReceipt; readonly wait?: (delayMs: number) => Promise; }; @@ -38,10 +37,8 @@ export type WorkflowPublicationObservationInput = { export type WorkflowPublicationObservationResult = | { readonly kind: 'observed'; - readonly catalog: ScopeServiceRevisionCatalogSnapshot; readonly publishedServiceId: string; - readonly revision: StudioScopeBindingRevision; - readonly workflow: ScopeWorkflowDetail; + readonly run: StudioMemberBindingRunStatusResponse; } | { readonly kind: 'delayed' }; @@ -71,116 +68,28 @@ function statusOf(error: unknown): number | undefined { return undefined; } -function codeOf(error: unknown): string | undefined { - if ( - error && - typeof error === 'object' && - 'code' in error && - typeof error.code === 'string' - ) { - return error.code.trim(); - } - - return undefined; -} - -function normalizeStatus(value: string): string { - return value.toLowerCase().replace(/[\s_-]+/g, ''); -} - -function isTerminalRevisionFailure( - revision: StudioScopeBindingRevision, -): boolean { - const status = normalizeStatus(revision.status); - return ( - status === 'preparationfailed' || - status === 'retired' || - Boolean(revision.failureReason.trim()) - ); -} - -function assertAcceptedWorkflowIdentity( - receipt: WorkflowPublicationReceipt, - workflow: ScopeWorkflowDetail, -): void { - if (workflow.scopeId !== receipt.scopeId) { - throw new Error('The observed workflow does not match the accepted scope.'); - } - if (workflow.workflow) { - if (workflow.workflow.scopeId !== receipt.scopeId) { - throw new Error( - 'The observed workflow does not match the accepted scope.', - ); - } - if (workflow.workflow.workflowId !== receipt.workflowId) { - throw new Error( - 'The observed workflow does not match the accepted workflow.', - ); - } - } -} - -function assertAcceptedCatalogIdentity( +function assertAcceptedBindingRunIdentity( receipt: WorkflowPublicationReceipt, - publishedServiceId: string, - catalog: ScopeServiceRevisionCatalogSnapshot, + run: StudioMemberBindingRunStatusResponse, ): void { if ( - catalog.scopeId !== receipt.scopeId || - catalog.serviceId !== publishedServiceId + run.scopeId !== receipt.scopeId || + run.memberId !== receipt.memberId || + run.bindingRunId !== receipt.bindingRunId ) { throw new Error( - 'The observed service catalog does not match the accepted service.', + 'The observed binding run does not match the accepted publication.', ); } } -function assertRevisionImplementsWorkflow( - workflow: ScopeWorkflowDetail, - revision: StudioScopeBindingRevision, -): void { - if ( - workflow.workflow && - revision.workflowDefinitionActorId !== workflow.workflow.actorId - ) { - throw new Error( - 'The accepted service revision does not implement the observed workflow.', - ); - } -} - -function matchesAcceptedPublication( - receipt: WorkflowPublicationReceipt, - publishedServiceId: string, - workflow: ScopeWorkflowDetail, - catalog: ScopeServiceRevisionCatalogSnapshot, - revision: StudioScopeBindingRevision, -): boolean { - return ( - workflow.available === true && - workflow.scopeId === receipt.scopeId && - workflow.workflow?.scopeId === receipt.scopeId && - workflow.workflow?.workflowId === receipt.workflowId && - workflow.workflow?.activeRevisionId === receipt.revisionId && - catalog.scopeId === receipt.scopeId && - catalog.serviceId === publishedServiceId && - catalog.activeServingRevisionId === receipt.revisionId && - revision.revisionId === receipt.revisionId && - revision.implementationKind === 'workflow' && - normalizeStatus(revision.status) === 'published' && - revision.isActiveServing === true && - revision.isServingTarget === true && - revision.allocationWeight > 0 && - revision.workflowDefinitionActorId === workflow.workflow?.actorId && - normalizeStatus(revision.servingState) === 'active' - ); -} - -function isDelayedWorkflowRead(error: unknown): boolean { - const status = statusOf(error); +function readBindingRunFailureMessage( + run: StudioMemberBindingRunStatusResponse, +): string { return ( - status === 404 || - (status === 409 && DELAYED_WORKFLOW_CONFLICT_CODES.has(codeOf(error) ?? '')) + run.failure?.message?.trim() || + run.failure?.code.trim() || + 'The accepted workflow publication failed.' ); } @@ -193,65 +102,39 @@ export async function observeWorkflowPublication( for (const delayMs of delays) { if (delayMs > 0) await wait(delayMs); - let workflow: ScopeWorkflowDetail; + let run: StudioMemberBindingRunStatusResponse; try { - workflow = await input.readWorkflow( + run = await input.readBindingRun( input.receipt.scopeId, - input.receipt.workflowId, + input.receipt.memberId, + input.receipt.bindingRunId, ); } catch (error) { - if (isDelayedWorkflowRead(error)) continue; + if (statusOf(error) === 404) continue; throw error; } - assertAcceptedWorkflowIdentity(input.receipt, workflow); - const publishedServiceId = - workflow.workflow?.publishedServiceId.trim() ?? ''; - if (!workflow.available || !workflow.workflow || !publishedServiceId) - continue; + assertAcceptedBindingRunIdentity(input.receipt, run); - let catalog: ScopeServiceRevisionCatalogSnapshot; - try { - catalog = await input.readRevisions( - input.receipt.scopeId, - publishedServiceId, - ); - } catch (error) { - if (statusOf(error) === 404) continue; - throw error; + if (run.status === 'failed' || run.status === 'rejected') { + throw new Error(readBindingRunFailureMessage(run)); } - assertAcceptedCatalogIdentity(input.receipt, publishedServiceId, catalog); + if (ACTIVE_BINDING_RUN_STATUSES.has(run.status)) continue; + if (run.status !== 'succeeded') continue; - const revision = catalog?.revisions.find( - (candidate) => candidate.revisionId === input.receipt.revisionId, - ); - if (revision && isTerminalRevisionFailure(revision)) { + const publishedServiceId = run.result?.publishedServiceId.trim() ?? ''; + if (!publishedServiceId) { throw new Error( - 'The accepted workflow publication reached a terminal revision state.', + 'The succeeded binding run did not provide a published service identity.', ); } - if (revision) { - assertRevisionImplementsWorkflow(workflow, revision); + if (run.result?.revisionId !== input.receipt.revisionId) { + throw new Error( + 'The succeeded binding run does not match the accepted revision.', + ); } - if (!revision) continue; - if ( - matchesAcceptedPublication( - input.receipt, - publishedServiceId, - workflow, - catalog, - revision, - ) - ) { - return { - kind: 'observed', - workflow, - catalog, - publishedServiceId, - revision, - }; - } + return { kind: 'observed', publishedServiceId, run }; } return { kind: 'delayed' }; @@ -286,7 +169,8 @@ export function useWorkflowPublication( 'workflow-activity-vnext', 'workflow-publication', receipt?.scopeId ?? '', - receipt?.workflowId ?? '', + receipt?.memberId ?? '', + receipt?.bindingRunId ?? '', receipt?.revisionId ?? '', ], queryFn: () => { @@ -296,10 +180,8 @@ export function useWorkflowPublication( return observeWorkflowPublication({ receipt, - readWorkflow: (scopeId, workflowId) => - scopesApi.getWorkflowDetail(scopeId, workflowId), - readRevisions: (scopeId, serviceId) => - scopeRuntimeApi.getServiceRevisions(scopeId, serviceId), + readBindingRun: (scopeId, memberId, bindingRunId) => + studioApi.getMemberBindingRun(scopeId, memberId, bindingRunId), }); }, retry: false, @@ -327,9 +209,9 @@ export function useWorkflowPublication( ? query.data.publishedServiceId : '', retry, - revision: + run: phase === 'observed' && query.data?.kind === 'observed' - ? query.data.revision + ? query.data.run : null, } 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 index 5021cdde3a..a45426c3bc 100644 --- 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 @@ -164,12 +164,18 @@ jest.mock('@/shared/studio/api', () => ({ error.status === status, ), studioApi: { + archiveTeam: jest.fn(), authorWorkflow: jest.fn(), + createMember: jest.fn(), + createTeam: jest.fn(), createWorkflowDraft: jest.fn(), + deleteMember: jest.fn(), deleteWorkflowDraft: jest.fn(), getWorkspaceSettings: jest.fn(), getAuthSession: jest.fn(), getMember: jest.fn(), + getMemberBindingRun: jest.fn(), + getTeam: jest.fn(), getUserConfigRuntime: jest.fn(), getUserLlmSettings: jest.fn(), getWorkflow: jest.fn(), @@ -177,6 +183,7 @@ jest.mock('@/shared/studio/api', () => ({ getWorkflowDraftFile: jest.fn(), listMembers: jest.fn(), listWorkflowDrafts: jest.fn(), + bindMemberWorkflow: jest.fn(), parseYaml: jest.fn(), previewExplicitRequests: jest.fn(), publishWorkflow: jest.fn(), @@ -185,11 +192,13 @@ jest.mock('@/shared/studio/api', () => ({ saveUserLlmSettings: jest.fn(), serializeYaml: jest.fn(), updateMemberDisplayName: jest.fn(), + updateMemberImplementationRef: jest.fn(), updateWorkflowDraft: jest.fn(), }, })); jest.mock('@/shared/studio/explicitRequestConfirmation', () => ({ + confirmInteractiveExplicitRequestPreview: jest.fn(), createWorkflowRevisionIdentityCandidate: jest.fn(), })); @@ -326,12 +335,18 @@ jest.mock( ); const mockStudioApi = jest.requireMock('@/shared/studio/api').studioApi as { + archiveTeam: jest.Mock; authorWorkflow: jest.Mock; + createMember: jest.Mock; + createTeam: jest.Mock; createWorkflowDraft: jest.Mock; + deleteMember: jest.Mock; deleteWorkflowDraft: jest.Mock; getWorkspaceSettings: jest.Mock; getAuthSession: jest.Mock; getMember: jest.Mock; + getMemberBindingRun: jest.Mock; + getTeam: jest.Mock; getUserConfigRuntime: jest.Mock; getUserLlmSettings: jest.Mock; getWorkflow: jest.Mock; @@ -339,6 +354,7 @@ const mockStudioApi = jest.requireMock('@/shared/studio/api').studioApi as { getWorkflowDraftFile: jest.Mock; listMembers: jest.Mock; listWorkflowDrafts: jest.Mock; + bindMemberWorkflow: jest.Mock; parseYaml: jest.Mock; previewExplicitRequests: jest.Mock; publishWorkflow: jest.Mock; @@ -347,11 +363,15 @@ const mockStudioApi = jest.requireMock('@/shared/studio/api').studioApi as { saveUserLlmSettings: jest.Mock; serializeYaml: jest.Mock; updateMemberDisplayName: jest.Mock; + updateMemberImplementationRef: jest.Mock; updateWorkflowDraft: jest.Mock; }; const mockCreateWorkflowRevisionIdentityCandidate = jest.requireMock( '@/shared/studio/explicitRequestConfirmation', ).createWorkflowRevisionIdentityCandidate as jest.Mock; +const mockConfirmInteractiveExplicitRequestPreview = jest.requireMock( + '@/shared/studio/explicitRequestConfirmation', +).confirmInteractiveExplicitRequestPreview as jest.Mock; const mockScopesApi = jest.requireMock('@/shared/api/scopesApi').scopesApi as { getWorkflowDetail: jest.Mock; listWorkflows: jest.Mock; @@ -396,6 +416,16 @@ describe('Workflow Activity vNext catalogue', () => { members: [], nextPageToken: null, }); + mockStudioApi.deleteMember.mockResolvedValue({ + status: 'delete_accepted', + scopeId: 'scope-alpha', + memberId: 'm-alpha', + }); + mockStudioApi.archiveTeam.mockResolvedValue({ + status: 'accepted', + scopeId: 'scope-alpha', + teamId: 't-alpha', + }); mockStudioApi.updateMemberDisplayName.mockResolvedValue({ status: 'accepted', scopeId: 'scope-alpha', @@ -1602,7 +1632,7 @@ describe('Workflow Activity vNext catalogue', () => { ); }); - it('deletes only the editable draft and refreshes authoritative draft membership', async () => { + it('cleans the exact hidden authority before deleting the editable draft', async () => { mockLocation = '/scopes/scope-alpha/workflow-activity-vnext/workflows?view=drafts'; let draftRows = [ @@ -1634,6 +1664,46 @@ describe('Workflow Activity vNext catalogue', () => { updatedAt: '2026-08-03T10:00:00Z', }, ]); + mockStudioApi.listMembers.mockResolvedValue({ + scopeId: 'scope-alpha', + members: [ + { + memberId: 'm-other', + scopeId: 'scope-alpha', + displayName: 'Other workflow', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-other', + }, + lifecycleStage: 'active', + publishedServiceId: 'svc-other', + lastBoundRevisionId: 'rev-other', + teamId: 't-other', + createdAt: '2026-08-04T09:00:00Z', + updatedAt: '2026-08-04T09:00:00Z', + }, + { + memberId: 'm-alpha', + scopeId: 'scope-alpha', + displayName: 'Support triage', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-draft-alpha', + }, + lifecycleStage: 'active', + publishedServiceId: 'svc-alpha', + lastBoundRevisionId: 'rev-alpha', + teamId: 't-alpha', + createdAt: '2026-08-04T09:00:00Z', + updatedAt: '2026-08-04T09:00:00Z', + }, + ], + nextPageToken: null, + }); mockStudioApi.deleteWorkflowDraft.mockImplementation(async () => { draftRows = []; }); @@ -1660,6 +1730,17 @@ describe('Workflow Activity vNext catalogue', () => { 'scope-alpha', ), ); + expect(mockStudioApi.deleteMember).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + memberId: 'm-alpha', + }); + expect(mockStudioApi.archiveTeam).toHaveBeenCalledWith( + 'scope-alpha', + 't-alpha', + ); + expect(mockStudioApi.deleteMember).not.toHaveBeenCalledWith( + expect.objectContaining({ memberId: 'm-other' }), + ); await waitFor(() => expect(mockStudioApi.listWorkflowDrafts).toHaveBeenCalledTimes(2), ); @@ -2639,6 +2720,49 @@ describe('Workflow Activity vNext editor', () => { findings: [], }, }); + mockStudioApi.listMembers.mockResolvedValue({ + scopeId: 'scope-alpha', + members: [ + { + memberId: 'm-alpha', + scopeId: 'scope-alpha', + displayName: 'Committed source', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-committed-source', + }, + lifecycleStage: 'draft', + publishedServiceId: '', + lastBoundRevisionId: null, + teamId: 't-alpha', + createdAt: '2026-08-07T09:00:00Z', + updatedAt: '2026-08-07T09:00:00Z', + }, + ], + nextPageToken: null, + }); + mockConfirmInteractiveExplicitRequestPreview.mockResolvedValue([]); + mockStudioApi.bindMemberWorkflow.mockResolvedValue({ + status: 'accepted', + bindingRunId: 'bind-alpha', + scopeId: 'scope-alpha', + memberId: 'm-alpha', + ackStage: 'dispatch_accepted', + }); + mockStudioApi.getMemberBindingRun.mockResolvedValue({ + status: 'succeeded', + bindingRunId: 'bind-alpha', + scopeId: 'scope-alpha', + memberId: 'm-alpha', + result: { + publishedServiceId: 'svc-alpha', + revisionId: 'rev-preview-alpha', + implementationKind: 'workflow', + }, + failure: null, + }); }); afterEach(() => cleanupTestQueryClients()); @@ -2658,6 +2782,115 @@ describe('Workflow Activity vNext editor', () => { ).not.toBeInTheDocument(); }); + it('saves dirty content and publishes through the typed backing member binding run', async () => { + mockLocation = + '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-alpha'; + mockCreateWorkflowRevisionIdentityCandidate.mockReturnValue( + 'rev-preview-alpha', + ); + const savedYaml = + 'name: approval_flow_updated\nroles: []\nsteps:\n - id: step-alpha\n type: llm_call\n'; + const savedWorkflow = { + workflowId: 'wf-alpha', + name: 'Approval flow updated', + fileName: 'approval-flow.yaml', + filePath: '/workflows/approval-flow.yaml', + directoryId: 'directory-alpha', + directoryLabel: 'Workflows', + yaml: savedYaml, + updatedAtUtc: '2026-08-07T10:00:00Z', + document: { + name: 'approval_flow_updated', + roles: [], + steps: [{ id: 'step-alpha', type: 'llm_call' }], + }, + draftExists: true, + findings: [], + }; + mockStudioApi.getWorkflow.mockResolvedValue({ + ...savedWorkflow, + name: 'Approval flow', + yaml: savedYaml.replaceAll('updated', ''), + }); + mockStudioApi.listMembers.mockResolvedValue({ + scopeId: 'scope-alpha', + members: [ + { + memberId: 'm-alpha', + scopeId: 'scope-alpha', + displayName: 'Approval flow', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-alpha', + }, + lifecycleStage: 'draft', + publishedServiceId: '', + lastBoundRevisionId: null, + teamId: 't-alpha', + createdAt: '2026-08-07T09:00:00Z', + updatedAt: '2026-08-07T09:00:00Z', + }, + ], + nextPageToken: null, + }); + mockStudioApi.parseYaml.mockResolvedValue({ + document: savedWorkflow.document, + findings: [], + }); + mockStudioApi.serializeYaml.mockResolvedValue({ + yaml: savedYaml, + document: savedWorkflow.document, + findings: [], + }); + mockStudioApi.saveWorkflow.mockResolvedValue({ + kind: 'materialized', + workflow: savedWorkflow, + }); + mockStudioApi.previewExplicitRequests.mockResolvedValue({ + workflowId: 'wf-alpha', + revisionId: 'rev-preview-alpha', + items: [], + }); + + renderWithQueryClient(); + + fireEvent.change(await screen.findByLabelText('Workflow name'), { + target: { value: 'Approval flow updated' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + + await waitFor(() => + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + memberId: 'm-alpha', + displayName: 'Approval flow updated', + workflowId: 'wf-alpha', + revisionId: 'rev-preview-alpha', + workflowYamls: [savedYaml], + }), + ); + expect(mockStudioApi.saveWorkflow).toHaveBeenCalledTimes(1); + expect(mockStudioApi.saveWorkflow.mock.invocationCallOrder[0]).toBeLessThan( + mockStudioApi.previewExplicitRequests.mock.invocationCallOrder[0], + ); + expect(mockConfirmInteractiveExplicitRequestPreview).toHaveBeenCalledWith({ + workflowId: 'wf-alpha', + revisionId: 'rev-preview-alpha', + items: [], + }); + expect(mockStudioApi.publishWorkflow).not.toHaveBeenCalled(); + expect(mockStudioApi.getMemberBindingRun).toHaveBeenCalledWith( + 'scope-alpha', + 'm-alpha', + 'bind-alpha', + ); + expect( + await screen.findByRole('button', { name: 'Published' }), + ).toBeInTheDocument(); + }); + it('publishes a saved workflow in one click and waits for observed evidence before showing Published', async () => { mockLocation = '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-draft-alpha'; @@ -2698,64 +2931,60 @@ describe('Workflow Activity vNext editor', () => { }, findings: [], }); - mockScopeRuntimeApi.listServices.mockResolvedValue([ - { - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - tenantId: 'tenant-alpha', - appId: 'app-alpha', - namespace: 'scope-alpha', - serviceId: 'svc-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: 'rev-existing', - activeServingRevisionId: 'rev-existing', - deploymentId: 'deployment-existing', - primaryActorId: 'actor-existing', - deploymentStatus: 'active', - endpoints: [], - policyIds: [], - updatedAt: '2026-08-06T10:00:00Z', - }, - ]); + mockStudioApi.listMembers.mockResolvedValue({ + scopeId: 'scope-alpha', + members: [ + { + memberId: 'm-alpha', + scopeId: 'scope-alpha', + displayName: 'Workflow alpha', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-draft-alpha', + }, + lifecycleStage: 'active', + publishedServiceId: '', + lastBoundRevisionId: null, + teamId: 't-alpha', + createdAt: '2026-08-06T09:00:00Z', + updatedAt: '2026-08-06T09:00:00Z', + }, + ], + nextPageToken: null, + }); mockStudioApi.previewExplicitRequests.mockResolvedValue({ workflowId: 'wf-draft-alpha', revisionId: 'rev-preview-alpha', items: [], }); - mockStudioApi.publishWorkflow.mockResolvedValue({ + mockStudioApi.bindMemberWorkflow.mockResolvedValue({ + status: 'accepted', + bindingRunId: 'bind-alpha', scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - serviceKey: 'service-alpha', - revisionId: 'rev-preview-alpha', - acceptanceStage: 'accepted', - propagationStage: 'readmodel_propagating', + memberId: 'm-alpha', + ackStage: 'dispatch_accepted', }); - const observedWorkflow = { - available: true, + const observedBindingRun = { + status: 'succeeded', + bindingRunId: 'bind-alpha', scopeId: 'scope-alpha', - workflow: { - scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - displayName: 'Workflow alpha', - serviceKey: 'workflow-alpha', - workflowName: 'Workflow alpha', - actorId: 'actor-workflow-alpha', - activeRevisionId: 'rev-preview-alpha', - deploymentId: 'deployment-workflow-alpha', - deploymentStatus: 'Available', - serviceAppId: 'studio', - serviceNamespace: 'workflow-publications', + memberId: 'm-alpha', + result: { publishedServiceId: 'svc-alpha', - updatedAt: '2026-08-06T10:00:00Z', + revisionId: 'rev-preview-alpha', + implementationKind: 'workflow', }, - source: null, + failure: null, }; - let resolveWorkflowObservation: - | ((workflow: typeof observedWorkflow) => void) + let resolveBindingRunObservation: + | ((run: typeof observedBindingRun) => void) | undefined; - mockScopesApi.getWorkflowDetail.mockImplementation( + mockStudioApi.getMemberBindingRun.mockImplementation( () => new Promise((resolve) => { - resolveWorkflowObservation = resolve; + resolveBindingRunObservation = resolve; }), ); mockScopeRuntimeApi.getServiceRevisions.mockResolvedValue({ @@ -2820,15 +3049,16 @@ describe('Workflow Activity vNext editor', () => { ), ); await waitFor(() => - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledWith( - expect.objectContaining({ - revisionId: 'rev-preview-alpha', - scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - workflowYaml: - 'name: workflow_alpha\nroles: []\nsteps:\n - id: step-alpha\n type: llm_call\n', - }), - ), + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledWith({ + displayName: 'Workflow alpha', + memberId: 'm-alpha', + revisionId: 'rev-preview-alpha', + scopeId: 'scope-alpha', + workflowId: 'wf-draft-alpha', + workflowYamls: [ + 'name: workflow_alpha\nroles: []\nsteps:\n - id: step-alpha\n type: llm_call\n', + ], + }), ); expect(mockStudioApi.saveAndBindWorkflow).not.toHaveBeenCalled(); expect(mockScopeRuntimeApi.listServices).not.toHaveBeenCalled(); @@ -2841,7 +3071,7 @@ describe('Workflow Activity vNext editor', () => { screen.queryByRole('button', { name: 'Published' }), ).not.toBeInTheDocument(); - resolveWorkflowObservation?.(observedWorkflow); + resolveBindingRunObservation?.(observedBindingRun); expect( await screen.findByRole('button', { name: 'Published' }), @@ -2858,18 +3088,21 @@ describe('Workflow Activity vNext editor', () => { it.each([ { - returnedRevisionId: 'rev-returned-other', - returnedWorkflowId: 'wf-draft-alpha', - mismatch: 'revision', + returnedBindingRunId: 'bind-alpha', + returnedMemberId: 'm-other', + returnedScopeId: 'scope-alpha', + mismatch: 'member ID', }, { - returnedRevisionId: 'rev-preview-alpha', - returnedWorkflowId: 'wf-returned-other', - mismatch: 'workflow ID', + returnedBindingRunId: 'bind-alpha', + returnedMemberId: 'm-alpha', + returnedScopeId: 'scope-other', + mismatch: 'scope ID', }, ])('keeps a returned $mismatch mismatch visible without starting observation', async ({ - returnedRevisionId, - returnedWorkflowId, + returnedBindingRunId, + returnedMemberId, + returnedScopeId, }) => { mockLocation = '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-draft-alpha'; @@ -2910,49 +3143,50 @@ describe('Workflow Activity vNext editor', () => { }, findings: [], }); - mockScopeRuntimeApi.listServices.mockResolvedValue([ - { - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - tenantId: 'tenant-alpha', - appId: 'app-alpha', - namespace: 'scope-alpha', - serviceId: 'svc-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: 'rev-existing', - activeServingRevisionId: 'rev-existing', - deploymentId: 'deployment-existing', - primaryActorId: 'actor-existing', - deploymentStatus: 'active', - endpoints: [], - policyIds: [], - updatedAt: '2026-08-06T10:00:00Z', - }, - ]); + mockStudioApi.listMembers.mockResolvedValue({ + scopeId: 'scope-alpha', + members: [ + { + memberId: 'm-alpha', + scopeId: 'scope-alpha', + displayName: 'Workflow alpha', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-draft-alpha', + }, + lifecycleStage: 'active', + publishedServiceId: '', + lastBoundRevisionId: null, + teamId: 't-alpha', + createdAt: '2026-08-06T09:00:00Z', + updatedAt: '2026-08-06T09:00:00Z', + }, + ], + nextPageToken: null, + }); mockStudioApi.previewExplicitRequests.mockResolvedValue({ workflowId: 'wf-draft-alpha', revisionId: 'rev-preview-alpha', items: [], }); - mockStudioApi.publishWorkflow.mockResolvedValue({ - scopeId: 'scope-alpha', - workflowId: returnedWorkflowId, - revisionId: returnedRevisionId, - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - acceptanceStage: 'accepted', - propagationStage: 'readmodel_propagating', + mockStudioApi.bindMemberWorkflow.mockResolvedValue({ + status: 'accepted', + bindingRunId: returnedBindingRunId, + scopeId: returnedScopeId, + memberId: returnedMemberId, + ackStage: 'dispatch_accepted', }); renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - expect( - await screen.findByText("Publication couldn't be confirmed"), - ).toBeInTheDocument(); + await waitFor(() => expect(mockConsoleToast.error).toHaveBeenCalled()); expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled(); - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1); - expect(mockScopesApi.getWorkflowDetail).not.toHaveBeenCalled(); - expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledTimes(1); + expect(mockStudioApi.getMemberBindingRun).not.toHaveBeenCalled(); }); function arrangeSavedDraftPublication(): void { @@ -3013,84 +3247,57 @@ describe('Workflow Activity vNext editor', () => { updatedAt: '2026-08-06T10:00:00Z', }, ]); - mockStudioApi.previewExplicitRequests.mockResolvedValue({ - workflowId: 'wf-draft-alpha', - revisionId: 'rev-preview-alpha', - items: [], - }); - mockStudioApi.publishWorkflow.mockResolvedValue({ + mockStudioApi.listMembers.mockResolvedValue({ scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - revisionId: 'rev-preview-alpha', - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - acceptanceStage: 'accepted', - propagationStage: 'readmodel_propagating', - }); - } - + members: [ + { + memberId: 'm-alpha', + scopeId: 'scope-alpha', + displayName: 'Workflow alpha', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-draft-alpha', + }, + lifecycleStage: 'active', + publishedServiceId: '', + lastBoundRevisionId: null, + teamId: 't-alpha', + createdAt: '2026-08-06T09:00:00Z', + updatedAt: '2026-08-06T09:00:00Z', + }, + ], + nextPageToken: null, + }); + mockStudioApi.previewExplicitRequests.mockResolvedValue({ + workflowId: 'wf-draft-alpha', + revisionId: 'rev-preview-alpha', + items: [], + }); + mockConfirmInteractiveExplicitRequestPreview.mockResolvedValue([]); + mockStudioApi.bindMemberWorkflow.mockResolvedValue({ + status: 'accepted', + bindingRunId: 'bind-alpha', + scopeId: 'scope-alpha', + memberId: 'm-alpha', + ackStage: 'dispatch_accepted', + }); + } + function arrangeObservedWorkflowPublication(): void { arrangeSavedDraftPublication(); - mockScopesApi.getWorkflowDetail.mockResolvedValue({ - available: true, + mockStudioApi.getMemberBindingRun.mockResolvedValue({ + status: 'succeeded', + bindingRunId: 'bind-alpha', scopeId: 'scope-alpha', - workflow: { - scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - displayName: 'Workflow alpha', - serviceKey: 'workflow-alpha', - workflowName: 'Workflow alpha', - actorId: 'actor-workflow-alpha', - activeRevisionId: 'rev-preview-alpha', - deploymentId: 'deployment-workflow-alpha', - deploymentStatus: 'Available', - serviceAppId: 'studio', - serviceNamespace: 'workflow-publications', + memberId: 'm-alpha', + result: { publishedServiceId: 'svc-alpha', - updatedAt: '2026-08-06T10:00:00Z', + revisionId: 'rev-preview-alpha', + implementationKind: 'workflow', }, - source: null, - }); - mockScopeRuntimeApi.getServiceRevisions.mockResolvedValue({ - scopeId: 'scope-alpha', - serviceId: 'svc-alpha', - serviceKey: 'service-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: 'rev-preview-alpha', - activeServingRevisionId: 'rev-preview-alpha', - deploymentId: 'deployment-service-alpha', - deploymentStatus: 'Active', - primaryActorId: 'actor-service-alpha', - catalogStateVersion: 12, - catalogLastEventId: 'evt-service-alpha', - updatedAt: '2026-08-06T10:00:03Z', - revisions: [ - { - revisionId: 'rev-preview-alpha', - implementationKind: 'workflow', - status: 'Published', - artifactHash: 'artifact-publication-alpha', - failureReason: '', - isDefaultServing: true, - isActiveServing: true, - isServingTarget: true, - allocationWeight: 100, - servingState: 'Active', - deploymentId: 'deployment-service-alpha', - primaryActorId: 'actor-service-alpha', - createdAt: '2026-08-06T10:00:00Z', - preparedAt: '2026-08-06T10:00:01Z', - publishedAt: '2026-08-06T10:00:02Z', - retiredAt: null, - workflowName: 'Workflow alpha', - workflowDefinitionActorId: 'actor-workflow-alpha', - inlineWorkflowCount: 0, - scriptId: '', - scriptRevision: '', - scriptDefinitionActorId: '', - scriptSourceHash: '', - staticActorTypeName: '', - }, - ], + failure: null, }); } @@ -3105,6 +3312,92 @@ describe('Workflow Activity vNext editor', () => { await publishObservedWorkflow(); } + it('returns to idle without an error when explicit request confirmation is cancelled', async () => { + arrangeSavedDraftPublication(); + mockConfirmInteractiveExplicitRequestPreview.mockResolvedValue(null); + + renderWithQueryClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); + + await waitFor(() => + expect(mockConfirmInteractiveExplicitRequestPreview).toHaveBeenCalled(), + ); + expect(mockStudioApi.bindMemberWorkflow).not.toHaveBeenCalled(); + expect(mockStudioApi.publishWorkflow).not.toHaveBeenCalled(); + expect(mockConsoleToast.error).not.toHaveBeenCalled(); + expect( + await screen.findByRole('button', { name: 'Publish' }), + ).toBeEnabled(); + }); + + it('reports validation errors and warnings as deduplicated toasts without page alerts', async () => { + mockStudioApi.getWorkflow.mockResolvedValue({ + workflowId: 'wf-committed-source', + 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-07T10:00:00Z', + document: { name: 'committed_source', roles: [], steps: [] }, + draftExists: true, + findings: [ + { + code: 'STEP_INVALID', + level: 'error', + message: 'The step is invalid.', + path: '/steps/0', + }, + { + code: 'STEP_REVIEW', + level: 'warning', + message: 'Review the optional step.', + path: '/steps/1', + }, + ], + }); + + renderWithQueryClient(); + + await screen.findByDisplayValue('Committed source'); + await waitFor(() => + expect(mockConsoleToast.error).toHaveBeenCalledWith( + 'The step is invalid.', + ), + ); + expect(mockConsoleToast.warning).toHaveBeenCalledWith( + 'Review the optional step.', + ); + expect(document.querySelector('.wa-vnext__editor-alerts')).toBeNull(); + expect(document.querySelector('.ant-alert-error')).toBeNull(); + expect(document.querySelector('.ant-alert-warning')).toBeNull(); + }); + + it('reports a rejected binding run with a toast instead of a page alert', async () => { + arrangeSavedDraftPublication(); + mockStudioApi.getMemberBindingRun.mockResolvedValue({ + status: 'rejected', + bindingRunId: 'bind-alpha', + scopeId: 'scope-alpha', + memberId: 'm-alpha', + result: null, + failure: { + code: 'BINDING_REJECTED', + message: 'Binding was rejected.', + }, + }); + + renderWithQueryClient(); + fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); + + await waitFor(() => expect(mockConsoleToast.error).toHaveBeenCalled()); + expect(document.querySelector('#workflow-publication-status')).toBeNull(); + expect(document.querySelector('.ant-alert-error')).toBeNull(); + expect(screen.getByRole('button', { name: 'Check again' })).toBeEnabled(); + }); + it('shows publish blockers in the standard compact tooltip', async () => { mockStudioApi.getWorkflow.mockResolvedValue({ workflowId: 'wf-committed-source', @@ -3168,99 +3461,43 @@ describe('Workflow Activity vNext editor', () => { it('retries a failed publication observation without sending a second POST', async () => { arrangeSavedDraftPublication(); - mockScopesApi.getWorkflowDetail + mockStudioApi.getMemberBindingRun .mockRejectedValueOnce( Object.assign(new Error('HTTP 503'), { status: 503 }), ) .mockResolvedValueOnce({ - available: true, + status: 'succeeded', + bindingRunId: 'bind-alpha', scopeId: 'scope-alpha', - workflow: { - scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - displayName: 'Workflow alpha', - serviceKey: 'workflow-alpha', - workflowName: 'Workflow alpha', - actorId: 'actor-workflow-alpha', - activeRevisionId: 'rev-preview-alpha', - deploymentId: 'deployment-workflow-alpha', - deploymentStatus: 'Available', - serviceAppId: 'studio', - serviceNamespace: 'workflow-publications', + memberId: 'm-alpha', + result: { publishedServiceId: 'svc-alpha', - updatedAt: '2026-08-06T10:00:00Z', - }, - source: null, - }); - mockScopeRuntimeApi.getServiceRevisions.mockResolvedValue({ - scopeId: 'scope-alpha', - serviceId: 'svc-alpha', - serviceKey: 'service-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: 'rev-preview-alpha', - activeServingRevisionId: 'rev-preview-alpha', - deploymentId: 'deployment-service-alpha', - deploymentStatus: 'Active', - primaryActorId: 'actor-service-alpha', - catalogStateVersion: 12, - catalogLastEventId: 'evt-service-alpha', - updatedAt: '2026-08-06T10:00:00Z', - revisions: [ - { revisionId: 'rev-preview-alpha', implementationKind: 'workflow', - status: 'Published', - artifactHash: 'artifact-publication-alpha', - failureReason: '', - isDefaultServing: false, - isActiveServing: true, - isServingTarget: true, - allocationWeight: 100, - servingState: 'Active', - deploymentId: 'deployment-service-alpha', - primaryActorId: 'actor-service-alpha', - createdAt: '2026-08-06T10:00:00Z', - preparedAt: '2026-08-06T10:00:01Z', - publishedAt: '2026-08-06T10:00:02Z', - retiredAt: null, - workflowName: 'Workflow alpha', - workflowDefinitionActorId: 'actor-workflow-alpha', - inlineWorkflowCount: 0, - scriptId: '', - scriptRevision: '', - scriptDefinitionActorId: '', - scriptSourceHash: '', - staticActorTypeName: '', }, - ], - }); + failure: null, + }); renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - expect( - await screen.findByText("Publication couldn't be confirmed"), - ).toBeInTheDocument(); + await waitFor(() => expect(mockConsoleToast.error).toHaveBeenCalled()); expect( screen.getByRole('button', { name: 'Publish blocked · 1 issue' }), ).toHaveAttribute('aria-disabled', 'true'); fireEvent.click(screen.getByRole('button', { name: 'Check again' })); await waitFor(() => - expect(mockScopesApi.getWorkflowDetail).toHaveBeenCalledTimes(2), - ); - expect(mockScopesApi.getWorkflowDetail).toHaveBeenLastCalledWith( - 'scope-alpha', - 'wf-draft-alpha', + expect(mockStudioApi.getMemberBindingRun).toHaveBeenCalledTimes(2), ); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenCalledTimes(1); - expect(mockScopeRuntimeApi.getServiceRevisions).toHaveBeenLastCalledWith( + expect(mockStudioApi.getMemberBindingRun).toHaveBeenLastCalledWith( 'scope-alpha', - 'svc-alpha', + 'm-alpha', + 'bind-alpha', ); expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(1); - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1); + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledTimes(1); }); it.each([ @@ -3268,7 +3505,7 @@ describe('Workflow Activity vNext editor', () => { [403, "You don't have access to this workspace"], ])('keeps an accepted publication receipt mutation-locked after a %i observation', async (status, message) => { arrangeSavedDraftPublication(); - mockScopesApi.getWorkflowDetail.mockRejectedValue( + mockStudioApi.getMemberBindingRun.mockRejectedValue( Object.assign(new Error(`HTTP ${status}`), { status }), ); @@ -3276,22 +3513,24 @@ describe('Workflow Activity vNext editor', () => { fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - expect(await screen.findByText(message)).toBeInTheDocument(); + await waitFor(() => + expect(mockConsoleToast.error).toHaveBeenCalledWith(message), + ); expect( screen.getByRole('button', { name: 'Publish blocked · 1 issue' }), ).toHaveAttribute('aria-disabled', 'true'); fireEvent.click(screen.getByRole('button', { name: 'Check again' })); await waitFor(() => - expect(mockScopesApi.getWorkflowDetail).toHaveBeenCalledTimes(2), + expect(mockStudioApi.getMemberBindingRun).toHaveBeenCalledTimes(2), ); - expect(mockScopesApi.getWorkflowDetail).toHaveBeenLastCalledWith( + expect(mockStudioApi.getMemberBindingRun).toHaveBeenLastCalledWith( 'scope-alpha', - 'wf-draft-alpha', + 'm-alpha', + 'bind-alpha', ); - expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(1); - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1); + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledTimes(1); }); it('creates a fresh revision before republishing a failed publication receipt', async () => { @@ -3312,109 +3551,60 @@ describe('Workflow Activity vNext editor', () => { revisionId: freshRevisionId, items: [], }); - mockStudioApi.publishWorkflow + mockStudioApi.bindMemberWorkflow .mockResolvedValueOnce({ + status: 'accepted', + bindingRunId: 'bind-alpha', scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - revisionId: receiptRevisionId, - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - acceptanceStage: 'accepted', - propagationStage: 'readmodel_propagating', + memberId: 'm-alpha', + ackStage: 'dispatch_accepted', }) .mockResolvedValueOnce({ + status: 'accepted', + bindingRunId: 'bind-beta', scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - revisionId: freshRevisionId, - serviceKey: 'tenant-alpha/app-alpha/scope-alpha/svc-alpha', - acceptanceStage: 'accepted', - propagationStage: 'readmodel_propagating', + memberId: 'm-alpha', + ackStage: 'dispatch_accepted', }); - mockScopesApi.getWorkflowDetail - .mockRejectedValueOnce( - Object.assign(new Error('HTTP 503'), { status: 503 }), - ) + mockStudioApi.getMemberBindingRun .mockResolvedValueOnce({ - available: true, + status: 'rejected', + bindingRunId: 'bind-alpha', scopeId: 'scope-alpha', - workflow: { - scopeId: 'scope-alpha', - workflowId: 'wf-draft-alpha', - displayName: 'Workflow alpha', - serviceKey: 'workflow-alpha', - workflowName: 'Workflow alpha', - actorId: 'actor-workflow-alpha', - activeRevisionId: freshRevisionId, - deploymentId: 'deployment-workflow-alpha', - deploymentStatus: 'Available', - serviceAppId: 'studio', - serviceNamespace: 'workflow-publications', + memberId: 'm-alpha', + result: null, + failure: { code: 'BINDING_REJECTED', message: 'Binding rejected.' }, + }) + .mockResolvedValueOnce({ + status: 'succeeded', + bindingRunId: 'bind-beta', + scopeId: 'scope-alpha', + memberId: 'm-alpha', + result: { publishedServiceId: 'svc-alpha', - updatedAt: '2026-08-06T10:00:00Z', - }, - source: null, - }); - mockScopeRuntimeApi.getServiceRevisions.mockResolvedValue({ - scopeId: 'scope-alpha', - serviceId: 'svc-alpha', - serviceKey: 'service-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: freshRevisionId, - activeServingRevisionId: freshRevisionId, - deploymentId: 'deployment-service-alpha', - deploymentStatus: 'Active', - primaryActorId: 'actor-service-alpha', - catalogStateVersion: 12, - catalogLastEventId: 'evt-service-alpha', - updatedAt: '2026-08-06T10:00:00Z', - revisions: [ - { revisionId: freshRevisionId, implementationKind: 'workflow', - status: 'Published', - artifactHash: 'artifact-publication-beta', - failureReason: '', - isDefaultServing: false, - isActiveServing: true, - isServingTarget: true, - allocationWeight: 100, - servingState: 'Active', - deploymentId: 'deployment-service-alpha', - primaryActorId: 'actor-service-alpha', - createdAt: '2026-08-06T10:00:00Z', - preparedAt: '2026-08-06T10:00:01Z', - publishedAt: '2026-08-06T10:00:02Z', - retiredAt: null, - workflowName: 'Workflow alpha', - workflowDefinitionActorId: 'actor-workflow-alpha', - inlineWorkflowCount: 0, - scriptId: '', - scriptRevision: '', - scriptDefinitionActorId: '', - scriptSourceHash: '', - staticActorTypeName: '', }, - ], - }); + failure: null, + }); renderWithQueryClient(); fireEvent.click(await screen.findByRole('button', { name: 'Publish' })); - expect( - await screen.findByText("Publication couldn't be confirmed"), - ).toBeInTheDocument(); - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mockConsoleToast.error).toHaveBeenCalled()); + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole('button', { name: 'Retry' })); await waitFor(() => expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(2), ); await waitFor(() => - expect(mockStudioApi.publishWorkflow).toHaveBeenLastCalledWith( + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenLastCalledWith( expect.objectContaining({ revisionId: freshRevisionId }), ), ); - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(2); + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledTimes(2); expect( await screen.findByRole('button', { name: 'Published' }), ).toBeInTheDocument(); @@ -3422,24 +3612,9 @@ describe('Workflow Activity vNext editor', () => { it('retries delayed publication observation without sending a second POST', async () => { arrangeSavedDraftPublication(); - mockScopesApi.getWorkflowDetail.mockRejectedValue( + mockStudioApi.getMemberBindingRun.mockRejectedValue( Object.assign(new Error('HTTP 404'), { status: 404 }), ); - mockScopeRuntimeApi.getServiceRevisions.mockResolvedValue({ - scopeId: 'scope-alpha', - serviceId: 'svc-alpha', - serviceKey: 'service-alpha', - displayName: 'Service alpha', - defaultServingRevisionId: 'rev-existing', - activeServingRevisionId: 'rev-existing', - deploymentId: 'deployment-service-alpha', - deploymentStatus: 'Active', - primaryActorId: 'actor-service-alpha', - catalogStateVersion: 12, - catalogLastEventId: 'evt-service-alpha', - updatedAt: '2026-08-06T10:00:00Z', - revisions: [], - }); renderWithQueryClient(); @@ -3451,28 +3626,27 @@ describe('Workflow Activity vNext editor', () => { await act(async () => { await jest.advanceTimersByTimeAsync(0); }); - expect(mockScopesApi.getWorkflowDetail).toHaveBeenCalledTimes(1); - expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); + expect(mockStudioApi.getMemberBindingRun).toHaveBeenCalledTimes(1); await act(async () => { await jest.advanceTimersByTimeAsync(5_000); }); - expect( - screen.getByText('Publication is taking longer to appear'), - ).toBeInTheDocument(); + expect(mockConsoleToast.warning).toHaveBeenCalledWith( + 'Publication is taking longer to appear', + ); fireEvent.click(screen.getByRole('button', { name: 'Check again' })); await act(async () => { await jest.advanceTimersByTimeAsync(5_000); }); - expect(mockScopesApi.getWorkflowDetail).toHaveBeenCalledTimes(10); - expect(mockScopesApi.getWorkflowDetail).toHaveBeenLastCalledWith( + expect(mockStudioApi.getMemberBindingRun).toHaveBeenCalledTimes(10); + expect(mockStudioApi.getMemberBindingRun).toHaveBeenLastCalledWith( 'scope-alpha', - 'wf-draft-alpha', + 'm-alpha', + 'bind-alpha', ); - expect(mockScopeRuntimeApi.getServiceRevisions).not.toHaveBeenCalled(); expect(mockStudioApi.previewExplicitRequests).toHaveBeenCalledTimes(1); - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1); + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledTimes(1); } finally { jest.useRealTimers(); } @@ -3509,7 +3683,7 @@ describe('Workflow Activity vNext editor', () => { }); await waitFor(() => - expect(mockStudioApi.publishWorkflow).toHaveBeenCalledTimes(1), + expect(mockStudioApi.bindMemberWorkflow).toHaveBeenCalledTimes(1), ); }); @@ -3714,9 +3888,14 @@ describe('Workflow Activity vNext editor', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Save workflow' })); + await waitFor(() => + expect(mockConsoleToast.error).toHaveBeenCalledWith( + "Workflow was saved but couldn't be reopened", + ), + ); expect( - await screen.findByText("Workflow was saved but couldn't be reopened"), - ).toBeInTheDocument(); + screen.queryByText("Workflow was saved but couldn't be reopened"), + ).not.toBeInTheDocument(); expect(mockStudioApi.getWorkflowDraftFile).toHaveBeenCalledWith( 'wf-draft-api', 'scope-alpha', @@ -3815,12 +3994,17 @@ describe('Workflow Activity vNext editor', () => { renderWithQueryClient(); + await waitFor(() => + expect(mockConsoleToast.error).toHaveBeenCalledWith( + 'Workflow YAML could not be read.', + ), + ); + expect(mockConsoleToast.warning).toHaveBeenCalledWith( + 'A workflow step needs review.', + ); expect( - await screen.findByText('Workflow YAML could not be read.'), - ).toBeInTheDocument(); - expect( - screen.getByText('A workflow step needs review.'), - ).toBeInTheDocument(); + screen.queryByText('Workflow YAML could not be read.'), + ).not.toBeInTheDocument(); expect( screen.queryByText('yaml parser: unexpected token on line 3'), ).not.toBeInTheDocument(); @@ -4173,9 +4357,14 @@ describe('Workflow Activity vNext editor', () => { }); fireEvent.click(screen.getByRole('button', { name: 'Save workflow' })); + await waitFor(() => + expect(mockConsoleToast.error).toHaveBeenCalledWith( + "Workflow was saved but couldn't be reopened", + ), + ); expect( - await screen.findByText("Workflow was saved but couldn't be reopened"), - ).toBeInTheDocument(); + screen.queryByText("Workflow was saved but couldn't be reopened"), + ).not.toBeInTheDocument(); expect(within(inspector).getByLabelText('Instruction')).toBeEnabled(); }); @@ -5543,6 +5732,60 @@ describe('Workflow Activity vNext creation', () => { }); mockStudioApi.listWorkflowDrafts.mockResolvedValue([]); mockScopesApi.listWorkflows.mockResolvedValue([]); + mockStudioApi.listMembers.mockResolvedValue({ + scopeId: 'scope-alpha', + members: [], + nextPageToken: null, + }); + mockStudioApi.createTeam.mockResolvedValue({ + teamId: 't-created-alpha', + scopeId: 'scope-alpha', + displayName: 'Incident review', + description: '', + lifecycleStage: 'active', + memberCount: 0, + createdAt: '2026-08-07T10:00:00Z', + updatedAt: '2026-08-07T10:00:00Z', + }); + mockStudioApi.getTeam.mockResolvedValue({ + teamId: 't-created-alpha', + scopeId: 'scope-alpha', + displayName: 'Incident review', + description: '', + lifecycleStage: 'active', + memberCount: 1, + createdAt: '2026-08-07T10:00:00Z', + updatedAt: '2026-08-07T10:00:00Z', + }); + const linkedMember = { + memberId: 'm-created-alpha', + scopeId: 'scope-alpha', + displayName: 'Incident review', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-created-alpha', + }, + lifecycleStage: 'active', + publishedServiceId: '', + lastBoundRevisionId: null, + teamId: 't-created-alpha', + createdAt: '2026-08-07T10:00:00Z', + updatedAt: '2026-08-07T10:00:00Z', + }; + mockStudioApi.createMember.mockResolvedValue(linkedMember); + mockStudioApi.getMember.mockResolvedValue({ + summary: linkedMember, + implementationRef: linkedMember.implementationRef, + lastBinding: null, + currentBindingRun: null, + }); + mockStudioApi.updateMemberImplementationRef.mockResolvedValue({ + status: 'accepted', + scopeId: 'scope-alpha', + memberId: 'm-created-alpha', + }); }); afterEach(() => cleanupTestQueryClients()); diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.test.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.test.tsx index 14820700e2..cc53bedf11 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.test.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/NewWorkflowPage.test.tsx @@ -43,10 +43,16 @@ jest.mock('@/shared/studio/api', () => ({ ), studioApi: { authorWorkflow: jest.fn(), + createMember: jest.fn(), + createTeam: jest.fn(), createWorkflowDraft: jest.fn(), + getMember: jest.fn(), + getTeam: jest.fn(), getWorkspaceSettings: jest.fn(), + listMembers: jest.fn(), listWorkflowDrafts: jest.fn(), parseYaml: jest.fn(), + updateMemberImplementationRef: jest.fn(), }, })); @@ -74,10 +80,16 @@ jest.mock('@/shared/ui/ConsoleHeaderActions', () => ({ const mockStudioApi = jest.requireMock('@/shared/studio/api').studioApi as { authorWorkflow: jest.Mock; + createMember: jest.Mock; + createTeam: jest.Mock; createWorkflowDraft: jest.Mock; + getMember: jest.Mock; + getTeam: jest.Mock; getWorkspaceSettings: jest.Mock; + listMembers: jest.Mock; listWorkflowDrafts: jest.Mock; parseYaml: jest.Mock; + updateMemberImplementationRef: jest.Mock; }; const mockScopesApi = jest.requireMock('@/shared/api/scopesApi').scopesApi as { @@ -113,15 +125,141 @@ const materializedWorkflow = { }, } as const; +const hiddenTeam = { + teamId: 't-created-alpha', + scopeId: 'scope-alpha', + displayName: 'Incident review', + description: '', + lifecycleStage: 'active', + memberCount: 1, + createdAt: '2026-08-06T10:00:00Z', + updatedAt: '2026-08-06T10:00:00Z', +} as const; + +const hiddenMember = { + memberId: 'm-created-alpha', + scopeId: 'scope-alpha', + displayName: 'Incident review', + description: '', + implementationKind: 'workflow', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-created-alpha', + }, + lifecycleStage: 'created', + publishedServiceId: 'svc-created-alpha', + lastBoundRevisionId: null, + teamId: 't-created-alpha', + createdAt: '2026-08-06T10:00:00Z', + updatedAt: '2026-08-06T10:00:00Z', +} as const; + describe('New workflow save-target recovery', () => { beforeEach(() => { jest.clearAllMocks(); + mockStudioApi.listMembers.mockResolvedValue({ + scopeId: 'scope-alpha', + members: [], + }); + mockStudioApi.createTeam.mockResolvedValue(hiddenTeam); + mockStudioApi.getTeam.mockResolvedValue(hiddenTeam); + mockStudioApi.createMember.mockResolvedValue(hiddenMember); + mockStudioApi.getMember.mockResolvedValue({ + summary: hiddenMember, + implementationRef: hiddenMember.implementationRef, + lastBinding: null, + currentBindingRun: null, + }); + mockStudioApi.updateMemberImplementationRef.mockResolvedValue({ + status: 'accepted', + scopeId: 'scope-alpha', + memberId: 'm-created-alpha', + }); mockStudioApi.listWorkflowDrafts.mockResolvedValue([]); mockScopesApi.listWorkflows.mockResolvedValue([]); }); afterEach(() => cleanupTestQueryClients()); + it('creates one hidden Team and Member for the new Workflow before opening it', async () => { + mockStudioApi.getWorkspaceSettings.mockResolvedValue(readyWorkspace); + mockStudioApi.createWorkflowDraft.mockResolvedValue(materializedWorkflow); + + renderWithQueryClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Start blank' })); + fireEvent.change(screen.getByLabelText('Workflow name'), { + target: { value: 'Incident review' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create and open' })); + + await waitFor(() => + expect(mockStudioApi.createTeam).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + displayName: 'Incident review', + description: 'System-managed authority for one Workflow.', + }), + ); + expect(mockStudioApi.createMember).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + displayName: 'Incident review', + implementationKind: 'workflow', + teamId: 't-created-alpha', + }); + expect(mockStudioApi.updateMemberImplementationRef).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + memberId: 'm-created-alpha', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-created-alpha', + }, + }); + expect(history.push).toHaveBeenCalledWith( + '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-created-alpha', + ); + expect(screen.queryByText(/Team|Member/)).not.toBeInTheDocument(); + }); + + it('reuses a typed backing authority when retrying after a link response failure', async () => { + mockStudioApi.getWorkspaceSettings.mockResolvedValue(readyWorkspace); + mockStudioApi.createWorkflowDraft.mockResolvedValue(materializedWorkflow); + mockStudioApi.listMembers + .mockResolvedValueOnce({ scopeId: 'scope-alpha', members: [] }) + .mockResolvedValueOnce({ + scopeId: 'scope-alpha', + members: [hiddenMember], + }); + mockStudioApi.updateMemberImplementationRef.mockRejectedValueOnce( + new TypeError('Connection closed after dispatch'), + ); + + renderWithQueryClient(); + + fireEvent.click(await screen.findByRole('button', { name: 'Start blank' })); + fireEvent.change(screen.getByLabelText('Workflow name'), { + target: { value: 'Incident review' }, + }); + const create = screen.getByRole('button', { name: 'Create and open' }); + fireEvent.click(create); + + expect( + await screen.findByText("Workflow couldn't be created"), + ).toBeVisible(); + expect(history.push).not.toHaveBeenCalled(); + expect(mockStudioApi.createTeam).toHaveBeenCalledTimes(1); + expect(mockStudioApi.createMember).toHaveBeenCalledTimes(1); + + fireEvent.click(create); + + await waitFor(() => + expect(history.push).toHaveBeenCalledWith( + '/scopes/scope-alpha/workflow-activity-vnext/workflows/wf-created-alpha', + ), + ); + expect(mockStudioApi.createTeam).toHaveBeenCalledTimes(1); + expect(mockStudioApi.createMember).toHaveBeenCalledTimes(1); + }); + it('allows method selection and input while save locations are loading', () => { mockStudioApi.getWorkspaceSettings.mockReturnValue(new Promise(() => {})); 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 index 7fccff0ae0..d88e8d3a9e 100644 --- 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 @@ -14,8 +14,10 @@ import { history } from '@/shared/navigation/history'; import { isStudioApiStatus, studioApi } from '@/shared/studio/api'; import type { StudioValidationFinding, + StudioWorkflowFile, StudioWorkflowSaveResult, } from '@/shared/studio/models'; +import { provisionWorkflowBackingAuthority } from '@/shared/studio/workflowBackingAuthority'; import { useConsoleToast } from '@/shared/ui/ConsoleToast'; import { useDraftMaterialization } from '../hooks/useDraftMaterialization'; import { @@ -142,16 +144,28 @@ const NewWorkflowPage: React.FC<{ readonly scopeId: string }> = ({ [scopeId], ); + const provisionAndNavigate = React.useCallback( + async (workflow: StudioWorkflowFile) => { + await provisionWorkflowBackingAuthority({ + scopeId, + workflowId: workflow.workflowId, + workflowName: workflow.name, + }); + navigateToWorkflow(workflow.workflowId); + }, + [navigateToWorkflow, scopeId], + ); + const finishSave = React.useCallback( async (result: StudioWorkflowSaveResult) => { if (result.kind === 'materialized') { - navigateToWorkflow(result.workflow.workflowId); + await provisionAndNavigate(result.workflow); return; } const readable = await materialization.observe(result.receipt); - if (readable) navigateToWorkflow(readable.workflowId); + if (readable) await provisionAndNavigate(readable); }, - [materialization.observe, navigateToWorkflow], + [materialization.observe, provisionAndNavigate], ); const persistDraft = async (nextYaml: string, workflowName: string) => { @@ -242,7 +256,7 @@ const NewWorkflowPage: React.FC<{ readonly scopeId: string }> = ({ const retryObservation = async () => { const readable = await materialization.retry(); - if (readable) navigateToWorkflow(readable.workflowId); + if (readable) await provisionAndNavigate(readable); }; const selectedTemplate = BUNDLED_WORKFLOW_TEMPLATES.find( diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx index e6f1a8b67a..774d2f3051 100644 --- a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/WorkflowEditorPage.tsx @@ -7,6 +7,7 @@ import { RocketOutlined, SaveOutlined, } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; import { Alert, Button, Input, Modal, Segmented, Space, Tooltip } from 'antd'; import React from 'react'; import WorkflowStudioEditorSurface from '@/pages/team-member-workflow-studio/components/WorkflowStudioEditorSurface'; @@ -14,7 +15,11 @@ import { formatUtcDateTime } from '@/shared/datetime/dateTime'; import { t } from '@/shared/i18n/messages'; import { getLocationSnapshot, history } from '@/shared/navigation/history'; import { studioApi } from '@/shared/studio/api'; -import { createWorkflowRevisionIdentityCandidate } from '@/shared/studio/explicitRequestConfirmation'; +import { + confirmInteractiveExplicitRequestPreview, + createWorkflowRevisionIdentityCandidate, +} from '@/shared/studio/explicitRequestConfirmation'; +import { resolveWorkflowBackingAuthority } from '@/shared/studio/workflowBackingAuthority'; import { useConsoleToast } from '@/shared/ui/ConsoleToast'; import { getRunStatusPresentation, @@ -82,6 +87,20 @@ const WorkflowEditorPage: React.FC<{ const activeScopeId = activeEditorRoute.scopeId; const activeWorkflowId = activeEditorRoute.workflowId; const editor = useWorkflowEditor(activeScopeId, activeWorkflowId); + const backingAuthority = useQuery({ + queryKey: [ + 'workflow-activity-vnext', + 'workflow-backing-authority', + activeScopeId, + activeWorkflowId, + ], + queryFn: async () => + resolveWorkflowBackingAuthority({ + workflowId: activeWorkflowId, + members: (await studioApi.listMembers(activeScopeId)).members, + }), + retry: false, + }); const toast = useConsoleToast(); const [mode, setMode] = React.useState<'canvas' | 'yaml'>('canvas'); const [nodeLibraryOpen, setNodeLibraryOpen] = React.useState(false); @@ -102,9 +121,16 @@ const WorkflowEditorPage: React.FC<{ const inspectorRef = React.useRef(null); const publicationGenerationRef = React.useRef(0); const publicationInFlightRef = React.useRef(false); + const findingsRouteKeyRef = React.useRef(''); + const notifiedFindingKeysRef = React.useRef(new Set()); + const publicationNotificationKeyRef = React.useRef(''); + const materializationNotificationKeyRef = React.useRef(''); const runRequested = new URLSearchParams(location.search).get('run') === '1'; const editorWriteLocked = editor.saving || editor.structuralMutationPending; const publication = useWorkflowPublication(publicationReceipt); + const effectivePublicationPhase = publicationReceipt + ? publication.phase + : publicationStage; const publicationObserved = publication.phase === 'observed'; const publicationStale = Boolean( publicationReceipt && @@ -320,6 +346,113 @@ const WorkflowEditorPage: React.FC<{ ); }, [editor.canvasMutationError, toast]); + React.useEffect(() => { + const routeKey = `${activeScopeId}\u0000${activeWorkflowId}`; + if (findingsRouteKeyRef.current !== routeKey) { + findingsRouteKeyRef.current = routeKey; + notifiedFindingKeysRef.current.clear(); + } + + for (const finding of editor.findings) { + const key = [ + finding.level, + finding.code, + finding.path ?? '', + finding.message, + ].join('|'); + if (notifiedFindingKeysRef.current.has(key)) continue; + notifiedFindingKeysRef.current.add(key); + if (String(finding.level).toLowerCase() === 'error') { + toast.error(finding.message); + } else { + toast.warning(finding.message); + } + } + }, [activeScopeId, activeWorkflowId, editor.findings, toast]); + + React.useEffect(() => { + const phase = editor.materialization.phase; + if (phase !== 'delayed' && phase !== 'failed') { + materializationNotificationKeyRef.current = ''; + return; + } + const key = `${phase}\u0000${errorMessage(editor.materialization.error ?? '')}`; + if (materializationNotificationKeyRef.current === key) return; + materializationNotificationKeyRef.current = key; + if (phase === 'delayed') { + toast.warning( + t( + 'workflowActivityVNext.editor.saveDelayed', + 'Save is taking longer than expected', + ), + ); + } else { + toast.error( + t( + 'workflowActivityVNext.editor.saveOpenFailed', + "Workflow was saved but couldn't be reopened", + ), + ); + } + }, [editor.materialization.error, editor.materialization.phase, toast]); + + React.useEffect(() => { + if ( + effectivePublicationPhase !== 'delayed' && + effectivePublicationPhase !== 'failed' && + effectivePublicationPhase !== 'unauthorized' && + effectivePublicationPhase !== 'forbidden' + ) { + publicationNotificationKeyRef.current = ''; + return; + } + + const key = [ + effectivePublicationPhase, + publicationReceipt?.bindingRunId ?? '', + errorMessage(publicationError ?? publication.error ?? ''), + ].join('|'); + if (publicationNotificationKeyRef.current === key) return; + publicationNotificationKeyRef.current = key; + + if (effectivePublicationPhase === 'delayed') { + toast.warning( + t( + 'workflowActivityVNext.publish.delayed', + 'Publication is taking longer to appear', + ), + ); + return; + } + if (effectivePublicationPhase === 'unauthorized') { + toast.error( + t('workflowActivityVNext.state.unauthorized', 'Sign in to continue'), + ); + return; + } + if (effectivePublicationPhase === 'forbidden') { + toast.error( + t( + 'workflowActivityVNext.state.forbidden', + "You don't have access to this workspace", + ), + ); + return; + } + toast.error( + t( + 'workflowActivityVNext.publish.failed', + "Publication couldn't be confirmed", + ), + ); + }, [ + effectivePublicationPhase, + publication.error, + publicationError, + publicationReceipt?.bindingRunId, + toast, + ]); + const retryMaterialization = React.useCallback(async () => { await editor.retryMaterialization(); }, [editor.retryMaterialization]); @@ -362,42 +495,40 @@ const WorkflowEditorPage: React.FC<{ 'The publication preparation does not match the saved workflow.', ); } - if ( - preview.items.some( - (item) => !item.allowedExecutionModes.includes('interactive'), - ) - ) { + const explicitRequestConfirmations = + await confirmInteractiveExplicitRequestPreview(preview); + if (explicitRequestConfirmations === null) { + setPublicationStage('idle'); + return; + } + + const authorityResult = await backingAuthority.refetch(); + if (authorityResult.error) throw authorityResult.error; + const authority = authorityResult.data; + if (!authority) { throw new Error( - 'An external request is unavailable for interactive publication.', + 'The workflow publication authority could not be resolved.', ); } - const explicitRequestConfirmations = preview.items.map((item) => ({ - workflowId: preview.workflowId, - revisionId: preview.revisionId, - callSiteId: item.callSiteId, - requestContractDigest: item.requestContractDigest, - attestedRisk: item.effectiveRisk, - })); - const result = await studioApi.publishWorkflow({ + const result = await studioApi.bindMemberWorkflow({ displayName: preparation.workflowName, - explicitRequestConfirmations, - inlineWorkflowYamls: {}, + ...(explicitRequestConfirmations.length > 0 + ? { explicitRequestConfirmations } + : {}), + memberId: authority.memberId, revisionId: preview.revisionId, scopeId: activeScopeId, workflowId: preparation.workflowId, - workflowName: preparation.workflowName, - workflowYaml: preparation.workflowYaml, + workflowYamls: [preparation.workflowYaml], }); if (!isCurrentPublication()) return; if ( - result.acceptanceStage !== 'accepted' || + result.status !== 'accepted' || result.scopeId !== activeScopeId || - !hasNonBlankIdentifier(result.workflowId) || - result.workflowId !== preparation.workflowId || - !hasNonBlankIdentifier(result.revisionId) || - result.revisionId !== preview.revisionId + result.memberId !== authority.memberId || + !hasNonBlankIdentifier(result.bindingRunId) ) { throw new Error( 'The accepted publication response does not match the submitted workflow.', @@ -406,8 +537,10 @@ const WorkflowEditorPage: React.FC<{ setPublicationReceipt({ scopeId: result.scopeId, - revisionId: result.revisionId, - workflowId: result.workflowId, + workflowId: preparation.workflowId, + memberId: result.memberId, + bindingRunId: result.bindingRunId, + revisionId: preview.revisionId, }); setPublishedDocumentVersion(preparation.documentVersion); setPublicationStage('accepted'); @@ -418,7 +551,12 @@ const WorkflowEditorPage: React.FC<{ } finally { if (isCurrentPublication()) publicationInFlightRef.current = false; } - }, [activeScopeId, editor.documentVersion, editor.preparePublication]); + }, [ + activeScopeId, + backingAuthority.refetch, + editor.documentVersion, + editor.preparePublication, + ]); const saveAndLeave = async () => { if (await saveWorkflow()) { @@ -544,9 +682,6 @@ const WorkflowEditorPage: React.FC<{ const runDetailsHref = editor.sseRunId ? buildWorkflowActivityRunHref(activeScopeId, editor.sseRunId) : ''; - const publicationPhase = publicationReceipt - ? publication.phase - : publicationStage; const publicationObservationPending = publicationReceipt !== null && publication.phase !== 'observed'; const publicationActionPending = @@ -572,15 +707,6 @@ const WorkflowEditorPage: React.FC<{ ), }); } - if (editor.dirty) { - publishReadinessIssues.push({ - id: 'dirty', - message: t( - 'workflowActivityVNext.publish.saveChangesBeforePublishing', - 'Save your changes before publishing.', - ), - }); - } if (hasUnappliedNodeChanges) { publishReadinessIssues.push({ id: 'node-configuration', @@ -820,6 +946,28 @@ const WorkflowEditorPage: React.FC<{ > {saveStatus} + {(editor.materialization.phase === 'delayed' || + editor.materialization.phase === 'failed') && + editor.materialization.receipt ? ( + + ) : null} + {canRetryPublicationObservation || canRetryPublicationSubmission ? ( + + {canRetryPublicationObservation ? ( + + ) : null} + {canRetryPublicationSubmission || + publication.phase === 'failed' ? ( + + ) : null} + + ) : null} - {(editor.materialization.phase === 'readable' || - editor.materialization.phase === 'delayed' || - editor.materialization.phase === 'failed') && - editor.materialization.receipt ? ( - void retryMaterialization()}> - {t('workflowActivityVNext.new.retryObservation', 'Try again')} - - ) : undefined - } - description={ - editor.materialization.error ? ( - - {errorMessage(editor.materialization.error)} - - ) : undefined - } - message={ - editor.materialization.phase === 'delayed' - ? t( - 'workflowActivityVNext.editor.saveDelayed', - 'Save is taking longer than expected', - ) - : editor.materialization.phase === 'failed' - ? t( - 'workflowActivityVNext.editor.saveOpenFailed', - "Workflow was saved but couldn't be reopened", - ) - : t('workflowActivityVNext.editor.saved', 'Saved') - } - showIcon - type={ - editor.materialization.phase === 'failed' - ? 'error' - : editor.materialization.phase === 'delayed' - ? 'warning' - : 'success' - } - /> - ) : null} - {publicationPhase === 'delayed' || - publicationPhase === 'failed' || - publicationPhase === 'unauthorized' || - publicationPhase === 'forbidden' ? ( - - {canRetryPublicationObservation ? ( - - ) : null} - {canRetryPublicationSubmission || - publication.phase === 'failed' ? ( - - ) : null} - - ) : undefined - } - description={ - <> - {publicationPhase === 'delayed' - ? t( - 'workflowActivityVNext.publish.delayedDescription', - 'Check again for the latest publication status.', - ) - : publicationPhase === 'unauthorized' - ? t( - 'workflowActivityVNext.publish.unauthorizedDescription', - 'Sign in again to check this publication.', - ) - : publicationPhase === 'forbidden' - ? t( - 'workflowActivityVNext.publish.forbiddenDescription', - "You don't have access to check this publication.", - ) - : t( - 'workflowActivityVNext.publish.failedDescription', - 'Resolve the error or try publishing again.', - )} - {publicationError || publication.error ? ( - - {errorMessage(publicationError ?? publication.error)} - - ) : null} - - } - message={ - publicationPhase === 'delayed' - ? t( - 'workflowActivityVNext.publish.delayed', - 'Publication is taking longer to appear', - ) - : publicationPhase === 'unauthorized' - ? t( - 'workflowActivityVNext.state.unauthorized', - 'Sign in to continue', - ) - : publicationPhase === 'forbidden' - ? t( - 'workflowActivityVNext.state.forbidden', - "You don't have access to this workspace", - ) - : t( - 'workflowActivityVNext.publish.failed', - "Publication couldn't be confirmed", - ) - } - id="workflow-publication-status" - showIcon - type={publicationPhase === 'delayed' ? 'warning' : 'error'} - /> - ) : null} - {editor.findings.length > 0 ? ( -
- {editor.findings.map((finding) => ( - - ))} -
- ) : null} {mode === 'canvas' ? ( = ({ scopeId }) => { ); const [deleteFailed, setDeleteFailed] = React.useState(false); const [deleteSucceeded, setDeleteSucceeded] = React.useState(false); + const [deleteAuthority, setDeleteAuthority] = + React.useState(null); const [deleting, setDeleting] = React.useState(false); const drafts = useQuery({ queryKey: ['workflow-activity-vnext', 'drafts', scopeId], @@ -538,6 +545,7 @@ const WorkflowsPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { setDeleteTarget(null); setDeleteFailed(false); setDeleteSucceeded(false); + setDeleteAuthority(null); }; const confirmDelete = async () => { @@ -547,15 +555,22 @@ const WorkflowsPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { let removed = deleteSucceeded; try { if (!removed) { - try { - await studioApi.deleteWorkflowDraft(deleteTarget.workflowId, scopeId); - removed = true; - setDeleteSucceeded(true); - } catch (error) { - if (!isStudioApiStatus(error, 404)) throw error; - removed = true; - setDeleteSucceeded(true); + let authority = deleteAuthority; + if (!authority) { + authority = resolveWorkflowBackingAuthority({ + members: (await studioApi.listMembers(scopeId)).members, + workflowId: deleteTarget.workflowId, + }); + setDeleteAuthority(authority); } + await cleanupWorkflowBackingAuthority({ + api: studioApi, + authority, + scopeId, + workflowId: deleteTarget.workflowId, + }); + removed = true; + setDeleteSucceeded(true); } suppressNextDraftListError.current = true; @@ -564,6 +579,7 @@ const WorkflowsPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { if (refreshed.isError) throw refreshed.error; setDeleteTarget(null); setDeleteSucceeded(false); + setDeleteAuthority(null); } catch { toast.error( removed @@ -1015,6 +1031,7 @@ const WorkflowsPage: React.FC<{ readonly scopeId: string }> = ({ scopeId }) => { setDeleteTarget(row); setDeleteFailed(false); setDeleteSucceeded(false); + setDeleteAuthority(null); } if (key === 'archive') openArchive(row); }, diff --git a/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts new file mode 100644 index 0000000000..2d64be33c3 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/workflow-activity-vnext/workflows/workflowBackingAuthority.test.ts @@ -0,0 +1,330 @@ +import { StudioApiError } from '@/shared/studio/api'; +import type { + StudioMemberDetail, + StudioMemberSummary, + StudioTeamSummary, +} from '@/shared/studio/models'; +import { + cleanupWorkflowBackingAuthority, + provisionWorkflowBackingAuthority, + resolveWorkflowBackingAuthority, +} from '@/shared/studio/workflowBackingAuthority'; + +function member( + changes: Partial & { + readonly memberId: string; + readonly workflowId?: string; + }, +): StudioMemberSummary { + return { + memberId: changes.memberId, + scopeId: 'scope-alpha', + displayName: 'Approval flow', + description: '', + implementationKind: 'workflow', + implementationRef: changes.workflowId + ? { + implementationKind: 'workflow', + workflowId: changes.workflowId, + } + : null, + lifecycleStage: 'created', + publishedServiceId: 'svc-alpha', + lastBoundRevisionId: null, + teamId: changes.teamId ?? 't-alpha', + createdAt: '2026-08-07T00:00:00Z', + updatedAt: '2026-08-07T00:00:00Z', + ...changes, + }; +} + +function memberDetail(summary: StudioMemberSummary): StudioMemberDetail { + return { + summary, + implementationRef: summary.implementationRef, + lastBinding: null, + currentBindingRun: null, + }; +} + +function team(): StudioTeamSummary { + return { + teamId: 't-alpha', + scopeId: 'scope-alpha', + displayName: 'Approval flow', + description: '', + lifecycleStage: 'active', + memberCount: 0, + createdAt: '2026-08-07T00:00:00Z', + updatedAt: '2026-08-07T00:00:00Z', + }; +} + +describe('resolveWorkflowBackingAuthority', () => { + it('resolves only the member with the exact typed workflow reference', () => { + expect( + resolveWorkflowBackingAuthority({ + workflowId: 'wf-alpha', + members: [ + member({ memberId: 'm-other', workflowId: 'wf-other' }), + member({ + memberId: 'm-alpha', + teamId: 't-alpha', + workflowId: 'wf-alpha', + }), + ], + }), + ).toEqual({ memberId: 'm-alpha', teamId: 't-alpha' }); + }); + + it('returns null when no typed workflow relationship exists', () => { + expect( + resolveWorkflowBackingAuthority({ + workflowId: 'wf-alpha', + members: [member({ memberId: 'wf-alpha' })], + }), + ).toBeNull(); + }); + + it('rejects duplicate authorities instead of selecting one', () => { + expect(() => + resolveWorkflowBackingAuthority({ + workflowId: 'wf-alpha', + members: [ + member({ memberId: 'm-alpha', workflowId: 'wf-alpha' }), + member({ + memberId: 'm-duplicate', + teamId: 't-duplicate', + workflowId: 'wf-alpha', + }), + ], + }), + ).toThrow('more than one backing authority'); + }); +}); + +describe('provisionWorkflowBackingAuthority', () => { + it('creates one Team and Member, then persists their typed workflow relationship', async () => { + const createdMember = member({ memberId: 'm-alpha' }); + const linkedMember = member({ + memberId: 'm-alpha', + workflowId: 'wf-alpha', + }); + const calls: string[] = []; + const api = { + createTeam: jest.fn(async () => { + calls.push('createTeam'); + return team(); + }), + getTeam: jest.fn(async () => { + calls.push('getTeam'); + return team(); + }), + createMember: jest.fn(async () => { + calls.push('createMember'); + return createdMember; + }), + getMember: jest + .fn() + .mockImplementationOnce(async () => { + calls.push('getMember'); + return memberDetail(createdMember); + }) + .mockImplementationOnce(async () => { + calls.push('getMemberLinked'); + return memberDetail(linkedMember); + }), + listMembers: jest.fn(async () => { + calls.push('listMembers'); + return { scopeId: 'scope-alpha', members: [] }; + }), + updateMemberImplementationRef: jest.fn(async () => { + calls.push('linkMember'); + return { + status: 'accepted' as const, + scopeId: 'scope-alpha', + memberId: 'm-alpha', + }; + }), + }; + + await expect( + provisionWorkflowBackingAuthority({ + api, + scopeId: 'scope-alpha', + workflowId: 'wf-alpha', + workflowName: 'Approval flow', + wait: async () => undefined, + }), + ).resolves.toEqual({ memberId: 'm-alpha', teamId: 't-alpha' }); + + expect(calls).toEqual([ + 'listMembers', + 'createTeam', + 'getTeam', + 'createMember', + 'getMember', + 'linkMember', + 'getMemberLinked', + ]); + expect(api.createMember).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + displayName: 'Approval flow', + implementationKind: 'workflow', + teamId: 't-alpha', + }); + expect(api.updateMemberImplementationRef).toHaveBeenCalledWith({ + scopeId: 'scope-alpha', + memberId: 'm-alpha', + implementationRef: { + implementationKind: 'workflow', + workflowId: 'wf-alpha', + }, + }); + }); + + it('reuses an existing typed relationship without creating resources', async () => { + const linkedMember = member({ + memberId: 'm-alpha', + workflowId: 'wf-alpha', + }); + const api = { + createTeam: jest.fn(), + getTeam: jest.fn(), + createMember: jest.fn(), + getMember: jest.fn(), + listMembers: jest.fn(async () => ({ + scopeId: 'scope-alpha', + members: [linkedMember], + })), + updateMemberImplementationRef: jest.fn(), + }; + + await expect( + provisionWorkflowBackingAuthority({ + api, + scopeId: 'scope-alpha', + workflowId: 'wf-alpha', + workflowName: 'Approval flow', + wait: async () => undefined, + }), + ).resolves.toEqual({ memberId: 'm-alpha', teamId: 't-alpha' }); + expect(api.createTeam).not.toHaveBeenCalled(); + expect(api.createMember).not.toHaveBeenCalled(); + }); + + it('retries read-model 404s without creating another identity', async () => { + const notFound = new StudioApiError('Not Found', 404); + const createdMember = member({ memberId: 'm-alpha' }); + const linkedMember = member({ + memberId: 'm-alpha', + workflowId: 'wf-alpha', + }); + const api = { + createTeam: jest.fn(async () => team()), + getTeam: jest + .fn() + .mockRejectedValueOnce(notFound) + .mockResolvedValue(team()), + createMember: jest.fn(async () => createdMember), + getMember: jest + .fn() + .mockRejectedValueOnce(notFound) + .mockResolvedValueOnce(memberDetail(createdMember)) + .mockResolvedValueOnce(memberDetail(linkedMember)), + listMembers: jest.fn(async () => ({ + scopeId: 'scope-alpha', + members: [], + })), + updateMemberImplementationRef: jest.fn(async () => ({ + status: 'accepted' as const, + scopeId: 'scope-alpha', + memberId: 'm-alpha', + })), + }; + + await provisionWorkflowBackingAuthority({ + api, + scopeId: 'scope-alpha', + workflowId: 'wf-alpha', + workflowName: 'Approval flow', + wait: async () => undefined, + }); + + expect(api.createTeam).toHaveBeenCalledTimes(1); + expect(api.createMember).toHaveBeenCalledTimes(1); + expect(api.getTeam).toHaveBeenCalledTimes(2); + expect(api.getMember).toHaveBeenCalledTimes(3); + }); +}); + +describe('cleanupWorkflowBackingAuthority', () => { + it('deletes only the exact typed Member, its Team, and then the Workflow draft', async () => { + const calls: string[] = []; + const api = { + listMembers: jest.fn(async () => ({ + scopeId: 'scope-alpha', + members: [ + member({ + memberId: 'm-other', + teamId: 't-other', + workflowId: 'wf-other', + }), + member({ + memberId: 'm-alpha', + teamId: 't-alpha', + workflowId: 'wf-alpha', + }), + ], + })), + deleteMember: jest.fn(async ({ memberId }) => { + calls.push(`deleteMember:${memberId}`); + }), + archiveTeam: jest.fn(async (_scopeId, teamId) => { + calls.push(`archiveTeam:${teamId}`); + }), + deleteWorkflowDraft: jest.fn(async (workflowId) => { + calls.push(`deleteWorkflowDraft:${workflowId}`); + }), + }; + + await expect( + cleanupWorkflowBackingAuthority({ + api, + scopeId: 'scope-alpha', + workflowId: 'wf-alpha', + }), + ).resolves.toEqual({ memberId: 'm-alpha', teamId: 't-alpha' }); + expect(calls).toEqual([ + 'deleteMember:m-alpha', + 'archiveTeam:t-alpha', + 'deleteWorkflowDraft:wf-alpha', + ]); + expect(api.deleteMember).not.toHaveBeenCalledWith( + expect.objectContaining({ memberId: 'm-other' }), + ); + expect(api.archiveTeam).not.toHaveBeenCalledWith('scope-alpha', 't-other'); + }); + + it('treats already-cleaned Member, Team, and draft 404 responses as success', async () => { + const notFound = new StudioApiError('Not Found', 404); + const api = { + listMembers: jest.fn(async () => ({ + scopeId: 'scope-alpha', + members: [], + })), + deleteMember: jest.fn().mockRejectedValue(notFound), + archiveTeam: jest.fn().mockRejectedValue(notFound), + deleteWorkflowDraft: jest.fn().mockRejectedValue(notFound), + }; + + await expect( + cleanupWorkflowBackingAuthority({ + api, + authority: { memberId: 'm-alpha', teamId: 't-alpha' }, + scopeId: 'scope-alpha', + workflowId: 'wf-alpha', + }), + ).resolves.toEqual({ memberId: 'm-alpha', teamId: 't-alpha' }); + }); +}); diff --git a/apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts b/apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts new file mode 100644 index 0000000000..c5a305cfd2 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts @@ -0,0 +1,311 @@ +import { isStudioApiStatus, studioApi } from '@/shared/studio/api'; +import type { + StudioMemberDetail, + StudioMemberSummary, + StudioTeamSummary, +} from '@/shared/studio/models'; + +const MATERIALIZATION_ATTEMPTS = 8; +const MATERIALIZATION_DELAY_MS = 300; + +export type WorkflowBackingAuthority = { + readonly memberId: string; + readonly teamId: string; +}; + +export type WorkflowBackingAuthorityApi = Pick< + typeof studioApi, + | 'createMember' + | 'createTeam' + | 'getMember' + | 'getTeam' + | 'listMembers' + | 'updateMemberImplementationRef' +>; + +export type WorkflowBackingAuthorityCleanupApi = Pick< + typeof studioApi, + 'archiveTeam' | 'deleteMember' | 'deleteWorkflowDraft' | 'listMembers' +>; + +type Wait = (delayMs: number) => Promise; + +function trim(value: string | null | undefined): string { + return value?.trim() ?? ''; +} + +function defaultWait(delayMs: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, delayMs)); +} + +function memberWorkflowId(member: StudioMemberSummary): string { + if ( + member.implementationKind !== 'workflow' || + member.implementationRef?.implementationKind !== 'workflow' + ) { + return ''; + } + + return trim(member.implementationRef.workflowId); +} + +function toAuthority(member: StudioMemberSummary): WorkflowBackingAuthority { + const memberId = trim(member.memberId); + const teamId = trim(member.teamId); + if (!memberId || !teamId) { + throw new Error( + 'The Workflow backing authority is missing its Member or Team identity.', + ); + } + + return { memberId, teamId }; +} + +export function resolveWorkflowBackingAuthority(input: { + readonly members: readonly StudioMemberSummary[]; + readonly workflowId: string; +}): WorkflowBackingAuthority | null { + const workflowId = trim(input.workflowId); + if (!workflowId) { + throw new Error( + 'A Workflow identity is required to resolve its authority.', + ); + } + + const matches = input.members.filter( + (member) => memberWorkflowId(member) === workflowId, + ); + if (matches.length > 1) { + throw new Error( + `Workflow '${workflowId}' has more than one backing authority.`, + ); + } + + return matches[0] ? toAuthority(matches[0]) : null; +} + +async function pollReadable(input: { + readonly read: () => Promise; + readonly accept: (value: T) => boolean; + readonly wait: Wait; + readonly unavailableMessage: string; +}): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < MATERIALIZATION_ATTEMPTS; attempt += 1) { + try { + const value = await input.read(); + if (input.accept(value)) return value; + } catch (error) { + if (!isStudioApiStatus(error, 404)) throw error; + lastError = error; + } + + if (attempt < MATERIALIZATION_ATTEMPTS - 1) { + await input.wait(MATERIALIZATION_DELAY_MS); + } + } + + throw new Error( + input.unavailableMessage, + lastError instanceof Error ? { cause: lastError } : undefined, + ); +} + +async function waitForWorkflowTeamVisible(input: { + readonly api: WorkflowBackingAuthorityApi; + readonly scopeId: string; + readonly teamId: string; + readonly wait: Wait; +}): Promise { + return pollReadable({ + read: () => input.api.getTeam(input.scopeId, input.teamId), + accept: (team) => + trim(team.scopeId) === input.scopeId && + trim(team.teamId) === input.teamId, + wait: input.wait, + unavailableMessage: + 'The Workflow authority Team was accepted but is not readable yet.', + }); +} + +export async function waitForWorkflowMemberVisible(input: { + readonly api: WorkflowBackingAuthorityApi; + readonly memberId: string; + readonly scopeId: string; + readonly wait?: Wait; +}): Promise { + const wait = input.wait ?? defaultWait; + return pollReadable({ + read: () => input.api.getMember(input.scopeId, input.memberId), + accept: (member) => + trim(member.summary.scopeId) === input.scopeId && + trim(member.summary.memberId) === input.memberId, + wait, + unavailableMessage: + 'The Workflow authority Member was accepted but is not readable yet.', + }); +} + +export async function linkWorkflowMemberDraft(input: { + readonly api: WorkflowBackingAuthorityApi; + readonly memberId: string; + readonly scopeId: string; + readonly workflowId: string; + readonly wait?: Wait; +}): Promise { + const wait = input.wait ?? defaultWait; + await waitForWorkflowMemberVisible({ ...input, wait }); + + try { + await input.api.updateMemberImplementationRef({ + scopeId: input.scopeId, + memberId: input.memberId, + implementationRef: { + implementationKind: 'workflow', + workflowId: input.workflowId, + }, + }); + } catch (error) { + if (!isStudioApiStatus(error, 404)) throw error; + await waitForWorkflowMemberVisible({ ...input, wait }); + await input.api.updateMemberImplementationRef({ + scopeId: input.scopeId, + memberId: input.memberId, + implementationRef: { + implementationKind: 'workflow', + workflowId: input.workflowId, + }, + }); + } +} + +async function waitForWorkflowMemberLinked(input: { + readonly api: WorkflowBackingAuthorityApi; + readonly memberId: string; + readonly scopeId: string; + readonly workflowId: string; + readonly wait: Wait; +}): Promise { + return pollReadable({ + read: () => input.api.getMember(input.scopeId, input.memberId), + accept: (member) => + trim(member.summary.memberId) === input.memberId && + memberWorkflowId(member.summary) === input.workflowId, + wait: input.wait, + unavailableMessage: + 'The Workflow authority Member link was accepted but is not readable yet.', + }); +} + +export async function provisionWorkflowBackingAuthority(input: { + readonly api?: WorkflowBackingAuthorityApi; + readonly scopeId: string; + readonly workflowId: string; + readonly workflowName: string; + readonly wait?: Wait; +}): Promise { + const api = input.api ?? studioApi; + const scopeId = trim(input.scopeId); + const workflowId = trim(input.workflowId); + const workflowName = trim(input.workflowName); + const wait = input.wait ?? defaultWait; + if (!scopeId || !workflowId || !workflowName) { + throw new Error( + 'Scope, Workflow identity, and Workflow name are required for authority provisioning.', + ); + } + + const roster = await api.listMembers(scopeId); + const existing = resolveWorkflowBackingAuthority({ + members: roster.members, + workflowId, + }); + if (existing) return existing; + + const createdTeam = await api.createTeam({ + scopeId, + displayName: workflowName, + description: 'System-managed authority for one Workflow.', + }); + const teamId = trim(createdTeam.teamId); + if (!teamId) { + throw new Error('Workflow Team creation did not return a stable identity.'); + } + await waitForWorkflowTeamVisible({ api, scopeId, teamId, wait }); + + const createdMember = await api.createMember({ + scopeId, + displayName: workflowName, + implementationKind: 'workflow', + teamId, + }); + const memberId = trim(createdMember.memberId); + if (!memberId) { + throw new Error( + 'Workflow Member creation did not return a stable identity.', + ); + } + + await linkWorkflowMemberDraft({ + api, + scopeId, + memberId, + workflowId, + wait, + }); + await waitForWorkflowMemberLinked({ + api, + scopeId, + memberId, + workflowId, + wait, + }); + + return { memberId, teamId }; +} + +async function ignoreAlreadyCleaned(operation: () => Promise) { + try { + await operation(); + } catch (error) { + if (!isStudioApiStatus(error, 404)) throw error; + } +} + +export async function cleanupWorkflowBackingAuthority(input: { + readonly api?: WorkflowBackingAuthorityCleanupApi; + readonly authority?: WorkflowBackingAuthority | null; + readonly scopeId: string; + readonly workflowId: string; +}): Promise { + const api = input.api ?? studioApi; + const scopeId = trim(input.scopeId); + const workflowId = trim(input.workflowId); + if (!scopeId || !workflowId) { + throw new Error( + 'Scope and Workflow identity are required for authority cleanup.', + ); + } + + const authority = + input.authority === undefined + ? resolveWorkflowBackingAuthority({ + members: (await api.listMembers(scopeId)).members, + workflowId, + }) + : input.authority; + + if (authority) { + await ignoreAlreadyCleaned(() => + api.deleteMember({ scopeId, memberId: authority.memberId }), + ); + await ignoreAlreadyCleaned(() => + api.archiveTeam(scopeId, authority.teamId), + ); + } + await ignoreAlreadyCleaned(() => + api.deleteWorkflowDraft(workflowId, scopeId), + ); + + return authority; +} From b99f921088db89f852746b71b6764819a41449d3 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:05:37 +0800 Subject: [PATCH 11/15] Fix workflow publication locale guards --- .../src/locales/workflowActivityVNextMessages.en-US.ts | 3 +++ .../src/locales/workflowActivityVNextMessages.zh-CN.ts | 3 +++ .../src/shared/studio/workflowBackingAuthority.ts | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) 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 a1907e3be4..22a44504ef 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.en-US.ts @@ -83,6 +83,7 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.editor.backAria': 'Back to workflows', 'workflowActivityVNext.editor.canvas': 'Canvas', 'workflowActivityVNext.editor.canvasAria': 'Workflow canvas', + 'workflowActivityVNext.editor.canvasUpdateFailed': "Couldn't update workflow", 'workflowActivityVNext.editor.description': 'Build, test, and refine this workflow.', 'workflowActivityVNext.editor.discardLeave': 'Discard and leave', @@ -281,6 +282,8 @@ const workflowActivityVNextMessages = { 'workflowActivityVNext.nav.settings': 'Settings', 'workflowActivityVNext.nav.workflows': 'Workflows', 'workflowActivityVNext.new.back': 'Back to workflows', + 'workflowActivityVNext.new.backingAuthorityDescription': + 'System-managed authority for one Workflow.', 'workflowActivityVNext.new.changeMethod': 'Change method', 'workflowActivityVNext.new.chooserAria': 'Workflow creation methods', 'workflowActivityVNext.new.createBlank': 'Create and open', 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 6114d959ba..817e530f8e 100644 --- a/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/workflowActivityVNextMessages.zh-CN.ts @@ -87,6 +87,7 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.editor.backAria': '返回工作流列表', 'workflowActivityVNext.editor.canvas': '画布', 'workflowActivityVNext.editor.canvasAria': '工作流画布', + 'workflowActivityVNext.editor.canvasUpdateFailed': '无法更新工作流', 'workflowActivityVNext.editor.description': '构建、测试并完善这个工作流。', 'workflowActivityVNext.editor.discardLeave': '放弃并离开', 'workflowActivityVNext.editor.emptyCanvas': @@ -264,6 +265,8 @@ const workflowActivityVNextMessages: Record = 'workflowActivityVNext.nav.settings': '设置', 'workflowActivityVNext.nav.workflows': '工作流', 'workflowActivityVNext.new.back': '返回工作流列表', + 'workflowActivityVNext.new.backingAuthorityDescription': + '每个工作流专用的系统管理权限资源。', 'workflowActivityVNext.new.changeMethod': '更换创建方式', 'workflowActivityVNext.new.chooserAria': '工作流创建方式', 'workflowActivityVNext.new.createBlank': '创建并打开', diff --git a/apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts b/apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts index c5a305cfd2..121cadaf40 100644 --- a/apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts +++ b/apps/aevatar-console-web/src/shared/studio/workflowBackingAuthority.ts @@ -1,3 +1,4 @@ +import { t } from '@/shared/i18n/messages'; import { isStudioApiStatus, studioApi } from '@/shared/studio/api'; import type { StudioMemberDetail, @@ -225,7 +226,10 @@ export async function provisionWorkflowBackingAuthority(input: { const createdTeam = await api.createTeam({ scopeId, displayName: workflowName, - description: 'System-managed authority for one Workflow.', + description: t( + 'workflowActivityVNext.new.backingAuthorityDescription', + 'System-managed authority for one Workflow.', + ), }); const teamId = trim(createdTeam.teamId); if (!teamId) { From 6a0bf7d02a3d3bc5fc3e7b2c2596a725a1c66951 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:55:48 +0800 Subject: [PATCH 12/15] Fix workflow connection keyboard deletion --- .../hooks/useTeamMemberWorkflowStudio.ts | 43 +++++++++--------- .../index.test.tsx | 45 ++++++++++--------- .../team-member-workflow-studio/index.tsx | 5 ++- 3 files changed, 49 insertions(+), 44 deletions(-) diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts index 2b458868af..bc90bc9d5a 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/hooks/useTeamMemberWorkflowStudio.ts @@ -235,7 +235,7 @@ type TeamMemberWorkflowStudioState = { readonly closeNodeLibrary: () => void; readonly closeYamlPanel: () => void; readonly connectNodes: (sourceNodeId: string, targetNodeId: string) => void; - readonly deleteSelectedConnection: () => void; + readonly deleteSelectedConnection: (edgeId?: string) => void; readonly deleteSelectedNode: () => void; readonly dirty: boolean; readonly emptyDescription: string; @@ -3180,27 +3180,30 @@ export function useTeamMemberWorkflowStudio(): TeamMemberWorkflowStudioState { setSelectedNodeId(result.nodeId); markDraftDirty(); }, [editableDocument, markDraftDirty, selectedNodeId]); - const deleteSelectedConnection = React.useCallback(() => { - if (!editableDocument || !selectedEdgeId) { - return; - } + const deleteSelectedConnection = React.useCallback( + (edgeId: string = selectedEdgeId) => { + if (!editableDocument || !edgeId) { + return; + } - const connection = readConnectionFromGraphEdgeId(selectedEdgeId); - if (!connection) { - return; - } + const connection = readConnectionFromGraphEdgeId(edgeId); + if (!connection) { + return; + } - const result = removeStepConnection( - editableDocument, - connection.sourceStepId, - connection.targetStepId, - connection.branchLabel, - ); - setEditableDocument(result.document); - setSelectedEdgeId(''); - setSelectedNodeId(''); - markDraftDirty(); - }, [editableDocument, markDraftDirty, selectedEdgeId]); + const result = removeStepConnection( + editableDocument, + connection.sourceStepId, + connection.targetStepId, + connection.branchLabel, + ); + setEditableDocument(result.document); + setSelectedEdgeId(''); + setSelectedNodeId(''); + markDraftDirty(); + }, + [editableDocument, markDraftDirty, selectedEdgeId], + ); const updateSelectedStepConfiguration = React.useCallback( (parametersText: string) => { if (!editableDocument || !selectedStepDraft) { diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx index 27783349df..9a4a89d1bd 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.test.tsx @@ -58,6 +58,7 @@ jest.mock('@/shared/graphs/GraphCanvas', () => ({ edges?: Array<{ id?: string }>; onCanvasSelect?: () => void; onConnectNodes?: (sourceNodeId: string, targetNodeId: string) => void; + onDeleteEdges?: (edgeIds: string[]) => Promise | void; onEdgeSelect?: (edgeId: string) => void; onNodeLayoutChange?: ( nodes: Array<{ id?: string; position?: { x: number; y: number } }>, @@ -91,13 +92,24 @@ jest.mock('@/shared/graphs/GraphCanvas', () => ({ ), props.edges?.map((edge) => React.createElement( - 'button', - { - key: edge.id, - onClick: () => props.onEdgeSelect?.(String(edge.id ?? '')), - type: 'button', - }, - `edge:${edge.id}`, + React.Fragment, + { key: edge.id }, + React.createElement( + 'button', + { + onClick: () => props.onEdgeSelect?.(String(edge.id ?? '')), + type: 'button', + }, + `edge:${edge.id}`, + ), + React.createElement( + 'button', + { + onClick: () => props.onDeleteEdges?.([String(edge.id ?? '')]), + type: 'button', + }, + `delete edge:${edge.id}`, + ), ), ), React.createElement( @@ -3189,7 +3201,7 @@ describe('TeamMemberWorkflowStudioPage', () => { }); }); - it('deletes a selected connection without deleting either node', async () => { + it('deletes the connection requested by the canvas without deleting either node', async () => { window.history.replaceState( {}, '', @@ -3258,17 +3270,10 @@ describe('TeamMemberWorkflowStudioPage', () => { expect(screen.getByText('nodes:2')).toBeTruthy(); }); fireEvent.click( - screen.getByRole('button', { name: 'edge:edge:triage:publish:linear' }), + screen.getByRole('button', { + name: 'delete edge:edge:triage:publish:linear', + }), ); - openMoreActionsMenu(); - expect( - screen.getByRole('menuitem', { name: 'Delete selected connection' }), - ).toBeTruthy(); - closeOpenMenu(); - const confirmSpy = jest - .spyOn(window, 'confirm') - .mockImplementation(() => true); - clickMoreAction('Delete selected connection'); expect( screen.queryByRole('button', { name: 'edge:edge:triage:publish:linear', @@ -3279,10 +3284,6 @@ describe('TeamMemberWorkflowStudioPage', () => { expect( screen.queryByRole('button', { name: 'More workflow actions' }), ).toBeNull(); - expect(confirmSpy).toHaveBeenCalledWith( - 'Delete the selected connection? This cannot be undone.', - ); - confirmSpy.mockRestore(); fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { diff --git a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx index e1040ce2a8..3daba028a0 100644 --- a/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx +++ b/apps/aevatar-console-web/src/pages/team-member-workflow-studio/index.tsx @@ -340,8 +340,9 @@ const TeamMemberWorkflowStudioPage: React.FC = () => { onCloseNodeLibrary={studio.closeNodeLibrary} onConnectNodes={studio.connectNodes} onDeleteEdges={(edgeIds) => { - if (edgeIds.includes(studio.selectedEdgeId)) { - studio.deleteSelectedConnection(); + const [edgeId] = edgeIds; + if (edgeId) { + studio.deleteSelectedConnection(edgeId); } }} onDeleteNodes={(nodeIds) => { From 710381c747d52345e33de417d448bd96c8cbd401 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:45:09 +0800 Subject: [PATCH 13/15] Document workflow edge selection design --- ...rkflow-edge-selection-visibility-design.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md diff --git a/docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md b/docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md new file mode 100644 index 0000000000..e7d6c5d244 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md @@ -0,0 +1,45 @@ +# Workflow Edge Selection Visibility + +## Context + +Workflow Studio and Workflow Activity vNext both render editable workflow connections through the shared `GraphCanvas`. A normal linear connection is already rendered as a 2.5 px blue stroke. Selecting it currently changes the stroke to a similar blue and increases its width to only 3 px. At the fitted zoom used for workflows with several nodes, that 0.5 px difference is difficult to perceive. The arrow marker also keeps its normal appearance, so it does not reinforce the selection state. + +The edge is selected correctly in application state and React Flow. This change therefore addresses only the shared visual presentation. It does not alter workflow editing, deletion, saving, publishing, or backend behavior. + +## Design + +Selected edges will retain the existing Ant Design primary color while receiving three coordinated cues: + +- Increase the selected path stroke width to 4 px. +- Add a restrained blue drop shadow around the selected path so the state remains visible after canvas zooming. +- Update the arrow marker color to the same selected primary color. + +Normal linear and branch edges will retain their existing semantic colors and widths. Selection will remain static rather than animated to avoid unnecessary motion and visual noise in an operational editor. + +The styling will remain in the shared `GraphCanvas` edge decoration path so Team member Workflow Studio and Workflow Activity vNext use exactly the same behavior. No page-specific override or second edge component will be introduced. + +## State Flow + +The owning editor continues to provide `selectedEdgeId`. `GraphCanvas` compares that identifier with each rendered edge and decorates only the matching edge. Deselecting the edge restores the original edge style and marker configuration without mutating the source graph data. + +## Error Handling + +This is a deterministic presentation change and introduces no new asynchronous work or failure state. Existing editor error handling and toast behavior remain unchanged. + +## Verification + +Focused component coverage will verify that: + +- the selected edge receives the stronger stroke and drop shadow; +- the selected arrow marker uses the selected color; +- unselected edges preserve their original style and marker color; +- selecting an edge still preserves its other edge configuration. + +The existing authenticated local Workflow Activity vNext editor will then be used for a browser smoke check at its current fitted zoom. The selected connection must be immediately distinguishable from adjacent unselected connections without invoking Save, Publish, Run, or Delete. + +## Scope Boundaries + +- No publish code or publication contract changes. +- No workflow identity, routing, API, or backend changes. +- No animation or custom edge renderer. +- No changes to the meaning of linear and branch edge colors. From 83a2221fbc68553e95b6a998c43a6a95f9d43065 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:01:06 +0800 Subject: [PATCH 14/15] Plan workflow edge selection visibility --- ...8-10-workflow-edge-selection-visibility.md | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md diff --git a/docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md b/docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md new file mode 100644 index 0000000000..c8a4bfb9c5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md @@ -0,0 +1,271 @@ +# Workflow Edge Selection Visibility 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:** Make a selected workflow connection immediately distinguishable at fitted canvas zoom without changing normal edge semantics or workflow behavior. + +**Architecture:** Keep selection decoration in the shared `GraphCanvas` so Team member Workflow Studio and Workflow Activity vNext continue to use one implementation. Decorate only the selected edge by strengthening its path and marker while preserving every unrelated edge property and restoring the original presentation on deselection. + +**Tech Stack:** React 19, TypeScript, `@xyflow/react`, Jest, Testing Library, Biome + +--- + +## File Structure + +- Modify `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx`: add focused coverage for selected and unselected edge presentation. +- Modify `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx`: strengthen the selected path and clone an object marker definition with the selected color. +- Update `docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md`: mark completed steps as implementation proceeds. + +### Task 1: Lock the selected-edge visual contract + +**Files:** +- Test: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx` + +- [ ] **Step 1: Add the failing component test** + +Add this test inside the existing `describe('GraphCanvas', ...)` block: + +```tsx +it('makes the selected edge visually distinct without changing other edges', () => { + const styledEdges = [ + { + ...edges[0], + markerEnd: { + color: '#2F6FEC', + height: 11, + type: 'arrowclosed', + width: 11, + }, + style: { + opacity: 0.9, + stroke: '#2F6FEC', + strokeWidth: 2.5, + }, + }, + { + ...edges[0], + id: 'edge:publish:archive:linear', + markerEnd: { + color: '#8B5CF6', + height: 11, + type: 'arrowclosed', + width: 11, + }, + source: 'step:publish', + style: { + stroke: '#8B5CF6', + strokeWidth: 2.5, + }, + target: 'step:archive', + }, + ]; + + render( + , + ); + + const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; + const selectedEdge = reactFlowProps.edges[0]; + const unselectedEdge = reactFlowProps.edges[1]; + + expect(selectedEdge.selected).toBe(true); + expect(selectedEdge.style).toEqual( + expect.objectContaining({ + filter: 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))', + opacity: 0.9, + stroke: 'var(--ant-color-primary)', + strokeWidth: 4, + }), + ); + expect(selectedEdge.markerEnd).toEqual({ + color: '#1677ff', + height: 11, + type: 'arrowclosed', + width: 11, + }); + expect(unselectedEdge).toEqual( + expect.objectContaining({ + markerEnd: styledEdges[1].markerEnd, + selected: false, + style: styledEdges[1].style, + }), + ); +}); +``` + +- [ ] **Step 2: Run the test and verify the visual contract fails** + +Run: + +```bash +pnpm --dir apps/aevatar-console-web jest --runInBand src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: FAIL because the selected edge still has `strokeWidth: 3`, has no drop-shadow filter, and retains the original marker color. + +### Task 2: Implement the shared selected-edge decoration + +**Files:** +- Modify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx:573` +- Test: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx` + +- [ ] **Step 1: Add selected-edge presentation constants** + +Place these constants beside the other `GraphCanvas` presentation constants: + +```tsx +const SELECTED_EDGE_COLOR = '#1677ff'; +const SELECTED_EDGE_FILTER = + 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))'; +const SELECTED_EDGE_STROKE_WIDTH = 4; +``` + +- [ ] **Step 2: Apply the stronger path and marker presentation** + +Update the decorated edge returned from `localEdges.map(...)` so the selected edge uses the new constants and an object marker definition is cloned rather than mutated: + +```tsx +return { + ...edge, + selected: isSelected, + markerEnd: + isSelected && edge.markerEnd && typeof edge.markerEnd === 'object' + ? { + ...edge.markerEnd, + color: SELECTED_EDGE_COLOR, + } + : edge.markerEnd, + style: { + ...edge.style, + filter: isSelected ? SELECTED_EDGE_FILTER : edge.style?.filter, + stroke: isSelected + ? 'var(--ant-color-primary)' + : edge.style?.stroke, + strokeWidth: isSelected + ? SELECTED_EDGE_STROKE_WIDTH + : (edge.style?.strokeWidth ?? 1.5), + }, + labelStyle: { + ...edge.labelStyle, + fill: isSelected + ? 'var(--ant-color-primary)' + : edge.labelStyle?.fill, + }, +}; +``` + +String marker references remain unchanged because their shared marker definition cannot be safely recolored from `GraphCanvas`. + +- [ ] **Step 3: Run the focused test and verify it passes** + +Run: + +```bash +pnpm --dir apps/aevatar-console-web jest --runInBand src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: PASS for the complete `GraphCanvas.test.tsx` suite, including the new selected-edge contract. + +- [ ] **Step 4: Commit the test-driven implementation** + +```bash +git add apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx \ + apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx +git commit -m "Improve workflow edge selection visibility" +``` + +### Task 3: Run focused frontend validation + +**Files:** +- Verify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx` +- Verify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx` + +- [ ] **Step 1: Analyze the affected frontend scope** + +Run from the repository root: + +```bash +python3 ~/.codex/skills/frontend-incremental-pr/scripts/frontend_change_scope.py \ + --repo . \ + --base origin/feat/2026-08-04_workflow-activity-vnext +``` + +Expected: `aevatar-console-web` is the affected package, the two graph files are listed for static checking, and the analyzer identifies Jest as the relevant runner. + +- [ ] **Step 2: Run every dependency-related test reported by the analyzer** + +Use the analyzer's exact dependency-related Jest paths and explicitly include: + +```bash +pnpm --dir apps/aevatar-console-web jest --runInBand src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: all scoped suites pass. Do not substitute a full frontend test run. + +- [ ] **Step 3: Run changed-file Biome checks** + +```bash +pnpm --dir apps/aevatar-console-web exec biome check \ + src/shared/graphs/GraphCanvas.tsx \ + src/shared/graphs/GraphCanvas.test.tsx +``` + +Expected: both files pass. Do not run a local production build or full TypeScript check; GitHub CI owns those checks. + +- [ ] **Step 4: Review the final diff** + +```bash +git diff origin/feat/2026-08-04_workflow-activity-vnext...HEAD -- \ + apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx \ + apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx \ + docs/superpowers/specs/2026-08-10-workflow-edge-selection-visibility-design.md \ + docs/superpowers/plans/2026-08-10-workflow-edge-selection-visibility.md +git diff --check +``` + +Expected: the diff contains only the agreed edge-selection presentation, its test, and planning documents, with no publish changes or whitespace errors. + +### Task 4: Verify the authenticated editor and update PR #3276 + +**Files:** +- Browser verify: `apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx` +- PR update: `https://github.com/aevatarAI/aevatar/pull/3276` + +- [ ] **Step 1: Reuse the authenticated local editor** + +Open the existing Chrome tab at: + +```text +http://127.0.0.1:5174/scopes/ccb108c4-dcb3-473a-a0f7-e9859bb2f2a0/workflow-activity-vnext/workflows/e4c08548f56b473eb965c94df542463d +``` + +Expected: `weekly_report_five_nodes` renders with five nodes and four edges, without an authentication wall, startup failure, blank page, or initial API error. + +- [ ] **Step 2: Select one edge and inspect the result** + +Click one connection only. Do not invoke Save, Publish, Run, or Delete. + +Expected: the selected path has a computed 4 px stroke, a visible blue drop shadow, a synchronized selected arrow marker, and is immediately distinguishable from adjacent unselected edges at the fitted zoom. + +- [ ] **Step 3: Push the implementation commit** + +```bash +git push origin HEAD:fix/2026-08-06_one-click-workflow-publish +``` + +Expected: PR #3276 updates without force-pushing. + +- [ ] **Step 4: Update the PR verification evidence** + +Record the exact focused Jest and Biome commands and results in PR #3276. State explicitly: + +```markdown +- Full frontend suite/build: deferred to GitHub CI by personal local workflow policy +``` + +Do not wait for CI after the PR update unless the user requests CI monitoring. From 0be1edda35302aa309c2c9154e7a57b1c548a257 Mon Sep 17 00:00:00 2001 From: AbigailDeng <108705114+AbigailDeng@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:23:49 +0800 Subject: [PATCH 15/15] Improve workflow edge selection visibility --- .../src/shared/graphs/GraphCanvas.test.tsx | 79 +++++++++++++++++-- .../src/shared/graphs/GraphCanvas.tsx | 64 +++++++++------ 2 files changed, 114 insertions(+), 29 deletions(-) diff --git a/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx b/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx index 3c71111e72..437019f896 100644 --- a/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx +++ b/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.test.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import { act, render, screen } from '@testing-library/react'; +import * as React from 'react'; import GraphCanvas from './GraphCanvas'; const mockBackgroundRender = jest.fn(); @@ -24,11 +24,7 @@ jest.mock('@xyflow/react', () => { mockControlsRender(props); return null; }, - Handle: (props: { - className?: string; - position?: string; - type?: string; - }) => + Handle: (props: { className?: string; position?: string; type?: string }) => React.createElement('span', { className: props.className, 'data-position': props.position, @@ -155,6 +151,77 @@ describe('GraphCanvas', () => { expect(onDeleteEdges).toHaveBeenCalledWith(['edge:assert:publish:linear']); }); + it('makes the selected edge visually distinct without changing other edges', () => { + const styledEdges = [ + { + ...edges[0], + markerEnd: { + color: '#2F6FEC', + height: 11, + type: 'arrowclosed', + width: 11, + }, + style: { + opacity: 0.9, + stroke: '#2F6FEC', + strokeWidth: 2.5, + }, + }, + { + ...edges[0], + id: 'edge:publish:archive:linear', + markerEnd: { + color: '#8B5CF6', + height: 11, + type: 'arrowclosed', + width: 11, + }, + source: 'step:publish', + style: { + stroke: '#8B5CF6', + strokeWidth: 2.5, + }, + target: 'step:archive', + }, + ]; + + render( + , + ); + + const reactFlowProps = mockReactFlowRender.mock.calls.at(-1)?.[0] as any; + const selectedEdge = reactFlowProps.edges[0]; + const unselectedEdge = reactFlowProps.edges[1]; + + expect(selectedEdge.selected).toBe(true); + expect(selectedEdge.style).toEqual( + expect.objectContaining({ + filter: 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))', + opacity: 0.9, + stroke: 'var(--ant-color-primary)', + strokeWidth: 4, + }), + ); + expect(selectedEdge.markerEnd).toEqual({ + color: '#1677ff', + height: 11, + type: 'arrowclosed', + width: 11, + }); + expect(unselectedEdge).toEqual( + expect.objectContaining({ + markerEnd: styledEdges[1].markerEnd, + selected: false, + style: styledEdges[1].style, + }), + ); + }); + it('renders studio nodes with their product label instead of the backend step type id', () => { render(); diff --git a/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx b/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx index c4abe95e95..31af8b456e 100644 --- a/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx +++ b/apps/aevatar-console-web/src/shared/graphs/GraphCanvas.tsx @@ -1,6 +1,6 @@ import { - ApiOutlined, ApartmentOutlined, + ApiOutlined, AppstoreOutlined, CodeOutlined, DatabaseOutlined, @@ -9,31 +9,31 @@ import { UserOutlined, } from '@ant-design/icons'; import { + applyNodeChanges, Background, BackgroundVariant, Controls, + type Edge, + type FitViewOptions, Handle, MiniMap, + type Node, + type NodeChange, + type NodeProps, Position, ReactFlow, - applyNodeChanges, + type ReactFlowInstance, useEdgesState, useNodesState, useStore, - type Edge, - type FitViewOptions, - type Node, - type NodeChange, - type NodeProps, - type ReactFlowInstance, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import React, { useEffect, useLayoutEffect, useMemo } from 'react'; +import { t } from '@/shared/i18n/messages'; import { getStudioGraphCategory, type StudioGraphNodeData, } from '@/shared/studio/graph'; -import { t } from '@/shared/i18n/messages'; type GraphCanvasProps = { autoFitKey?: string; @@ -84,6 +84,9 @@ const STUDIO_FIT_VIEW_ATTEMPT_COUNT = 3; const STUDIO_NODE_WIDTH = 268; const STUDIO_NODE_COMPACT_WIDTH = 244; const STUDIO_NODE_COMPACT_ZOOM = 0.48; +const SELECTED_EDGE_COLOR = '#1677ff'; +const SELECTED_EDGE_FILTER = 'drop-shadow(0 0 3px rgba(22, 119, 255, 0.55))'; +const SELECTED_EDGE_STROKE_WIDTH = 4; const studioCanvasCss = ` .studio-canvas { background: #f7f9fc; @@ -327,7 +330,8 @@ function StudioWorkflowNode({ }: NodeProps>) { const category = getStudioGraphCategory(data.stepType); const Icon = - STUDIO_NODE_ICON_BY_CATEGORY[category.key] ?? STUDIO_NODE_ICON_BY_CATEGORY.custom; + STUDIO_NODE_ICON_BY_CATEGORY[category.key] ?? + STUDIO_NODE_ICON_BY_CATEGORY.custom; const zoom = useStore((state) => state.transform[2]); const compact = zoom < STUDIO_NODE_COMPACT_ZOOM; const width = compact ? STUDIO_NODE_COMPACT_WIDTH : STUDIO_NODE_WIDTH; @@ -370,10 +374,12 @@ function StudioWorkflowNode({ ] .filter(Boolean) .join(' ')} - style={{ - width, - '--studio-node-accent': category.color, - } as React.CSSProperties} + style={ + { + width, + '--studio-node-accent': category.color, + } as React.CSSProperties + } > = ({ }, [edges, setLocalEdges]); useLayoutEffect(() => { - if (!autoFitKey || !flowInstance || !isStudioVariant || nodes.length === 0) { + if ( + !autoFitKey || + !flowInstance || + !isStudioVariant || + nodes.length === 0 + ) { return; } @@ -525,12 +536,7 @@ const GraphCanvas: React.FC = ({ window.clearTimeout(timeoutId); }); }; - }, [ - autoFitKey, - flowInstance, - isStudioVariant, - nodes.length, - ]); + }, [autoFitKey, flowInstance, isStudioVariant, nodes.length]); const decoratedNodes = useMemo( () => @@ -577,12 +583,22 @@ const GraphCanvas: React.FC = ({ return { ...edge, selected: isSelected, + markerEnd: + isSelected && edge.markerEnd && typeof edge.markerEnd === 'object' + ? { + ...edge.markerEnd, + color: SELECTED_EDGE_COLOR, + } + : edge.markerEnd, style: { ...edge.style, + filter: isSelected ? SELECTED_EDGE_FILTER : edge.style?.filter, stroke: isSelected ? 'var(--ant-color-primary)' : edge.style?.stroke, - strokeWidth: isSelected ? 3 : (edge.style?.strokeWidth ?? 1.5), + strokeWidth: isSelected + ? SELECTED_EDGE_STROKE_WIDTH + : (edge.style?.strokeWidth ?? 1.5), }, labelStyle: { ...edge.labelStyle, @@ -709,7 +725,9 @@ const GraphCanvas: React.FC = ({ >