diff --git a/apps/aevatar-console-web/src/locales/en-US.ts b/apps/aevatar-console-web/src/locales/en-US.ts index 01152a70d2..50eaeabf55 100644 --- a/apps/aevatar-console-web/src/locales/en-US.ts +++ b/apps/aevatar-console-web/src/locales/en-US.ts @@ -809,7 +809,19 @@ const enUSMessages = { 'teams.automations.error.title': 'Automations could not load', 'teams.automations.form.cadence': 'Cadence', 'teams.automations.form.cadenceAria': 'Automation cadence', + 'teams.automations.form.agentKeyConsent': + 'I consent to Aevatar creating an automation-dedicated Agent Key for this schedule.', + 'teams.automations.form.agentKeyExpiry': 'Expires {time}', + 'teams.automations.form.agentKeyManaged': 'Aevatar managed', + 'teams.automations.form.agentKeyMode': 'Credential mode · {mode}', + 'teams.automations.form.agentKeyNoRawKey': + 'Browser never receives the raw Agent Key', + 'teams.automations.form.agentKeyPlan': + 'Automation dedicated Agent Key', + 'teams.automations.form.close': 'Close', 'teams.automations.form.create': 'Create automation', + 'teams.automations.form.createWithConsent': + 'Create with Agent Key consent', 'teams.automations.form.cron': 'Cron expression', 'teams.automations.form.cronAria': 'Cron expression', 'teams.automations.form.cronFiveFieldHint': @@ -828,11 +840,19 @@ const enUSMessages = { "Targets the member's published service.", 'teams.automations.form.member': 'Member', 'teams.automations.form.memberAria': 'Automation member', + 'teams.automations.form.nodeGrants': 'Node grants', + 'teams.automations.form.permissionDigest': + 'Permission digest · {permissionDigest}', + 'teams.automations.form.planChanged': + 'The authorization plan changed. Refresh the review before creating.', + 'teams.automations.form.policyVersion': + 'Policy version · {policyVersion}', 'teams.automations.form.preset.custom': 'Custom cron', 'teams.automations.form.preset.dailyMorning': 'Daily · 09:00', 'teams.automations.form.preset.hourly': 'Hourly', 'teams.automations.form.preset.weekdaysMorning': 'Weekdays · 09:00', 'teams.automations.form.preset.weeklyMonday': 'Monday · 09:00', + 'teams.automations.form.preparingReview': 'Preparing review', 'teams.automations.form.preview': 'Preview next runs', 'teams.automations.form.previewEmpty': 'Preview the cadence to confirm the next scheduled runs.', @@ -844,8 +864,20 @@ const enUSMessages = { 'Optional. Up to {maxLength} characters.', 'teams.automations.form.promptPlaceholder': 'Summarize escalations, blocked accounts, and follow-up owners.', + 'teams.automations.form.refreshReview': 'Refresh review', + 'teams.automations.form.reviewErrorBody': + 'The mock contract could not prepare the review. Keep the draft and try again.', + 'teams.automations.form.reviewErrorTitle': + 'Permission review needs attention', + 'teams.automations.form.reviewPermissions': 'Review permissions', + 'teams.automations.form.reviewPlaceholder': + 'Review is prepared after the draft cadence and target are ready.', 'teams.automations.form.save': 'Save changes', 'teams.automations.form.scheduleReadsAs': 'Schedule reads as', + 'teams.automations.form.section.permissionReview': + '4. Review Agent Key consent', + 'teams.automations.form.section.permissionReviewHint': + 'Browser login authorization only confirms this consent. Automation uses a dedicated Agent Key managed by Aevatar.', 'teams.automations.form.section.schedule': '3. Schedule', 'teams.automations.form.section.scheduleHint': 'Choose a common cadence or switch to custom cron for advanced schedules.', @@ -855,6 +887,7 @@ const enUSMessages = { 'teams.automations.form.section.work': '2. Work to run', 'teams.automations.form.section.workHint': 'Name the automation and optionally add a prompt for each run.', + 'teams.automations.form.serviceGrants': 'Service grants', 'teams.automations.form.timezone': 'Timezone', 'teams.automations.form.timezoneAria': 'Timezone', 'teams.automations.form.title': 'New member automation', @@ -867,7 +900,11 @@ const enUSMessages = { 'Only workflow members can have recurring work.', 'teams.automations.messages.createFailed': 'Automation was not created: {message}', + 'teams.automations.messages.createAccepted': + 'Automation creation request accepted.', 'teams.automations.messages.createSuccess': 'Automation created.', + 'teams.automations.messages.consentRequired': + 'Review and consent to the automation-dedicated Agent Key before creating.', 'teams.automations.messages.cronRequired': 'Enter a cron expression first.', 'teams.automations.messages.deleteSuccess': 'Automation deleted.', 'teams.automations.messages.disableSuccess': 'Automation paused.', @@ -881,6 +918,8 @@ const enUSMessages = { 'Service identity is still loading.', 'teams.automations.messages.serviceIdentityMissing': 'The selected member does not have a service identity yet.', + 'teams.automations.messages.reviewFailed': + 'Permission review could not be prepared: {message}', 'teams.automations.messages.updateFailed': 'Automation was not updated: {message}', 'teams.automations.messages.updateSuccess': 'Automation updated.', diff --git a/apps/aevatar-console-web/src/locales/zh-CN.ts b/apps/aevatar-console-web/src/locales/zh-CN.ts index d2b60c0cfa..8472544e50 100644 --- a/apps/aevatar-console-web/src/locales/zh-CN.ts +++ b/apps/aevatar-console-web/src/locales/zh-CN.ts @@ -766,9 +766,21 @@ const zhCNMessages = { 'teams.automations.error.description': '刷新页面,或等定时任务服务可用后再试。', 'teams.automations.error.title': '自动化加载失败', + 'teams.automations.form.agentKeyConsent': + 'I consent to Aevatar creating an automation-dedicated Agent Key for this schedule.', + 'teams.automations.form.agentKeyExpiry': 'Expires {time}', + 'teams.automations.form.agentKeyManaged': 'Aevatar managed', + 'teams.automations.form.agentKeyMode': 'Credential mode · {mode}', + 'teams.automations.form.agentKeyNoRawKey': + 'Browser never receives the raw Agent Key', + 'teams.automations.form.agentKeyPlan': + 'Automation dedicated Agent Key', 'teams.automations.form.cadence': '节奏', 'teams.automations.form.cadenceAria': '自动化节奏', + 'teams.automations.form.close': 'Close', 'teams.automations.form.create': '创建自动化', + 'teams.automations.form.createWithConsent': + 'Create with Agent Key consent', 'teams.automations.form.cron': 'Cron 表达式', 'teams.automations.form.cronAria': 'Cron 表达式', 'teams.automations.form.cronFiveFieldHint': @@ -786,11 +798,19 @@ const zhCNMessages = { 'teams.automations.form.identityReady': '目标为该成员的已发布服务。', 'teams.automations.form.member': '成员', 'teams.automations.form.memberAria': '自动化成员', + 'teams.automations.form.nodeGrants': 'Node grants', + 'teams.automations.form.permissionDigest': + 'Permission digest · {permissionDigest}', + 'teams.automations.form.planChanged': + 'The authorization plan changed. Refresh the review before creating.', + 'teams.automations.form.policyVersion': + 'Policy version · {policyVersion}', 'teams.automations.form.preset.custom': '自定义 Cron', 'teams.automations.form.preset.dailyMorning': '每天 · 09:00', 'teams.automations.form.preset.hourly': '每小时', 'teams.automations.form.preset.weekdaysMorning': '工作日 · 09:00', 'teams.automations.form.preset.weeklyMonday': '周一 · 09:00', + 'teams.automations.form.preparingReview': 'Preparing review', 'teams.automations.form.preview': '预览后续运行', 'teams.automations.form.previewEmpty': '先预览节奏,确认后续触发。', 'teams.automations.form.previewHint': '保存前通过定时任务服务预览。', @@ -799,8 +819,20 @@ const zhCNMessages = { 'teams.automations.form.promptLimit': '选填,最多 {maxLength} 个字符。', 'teams.automations.form.promptPlaceholder': '汇总升级工单、受阻账号和后续负责人。', + 'teams.automations.form.refreshReview': 'Refresh review', + 'teams.automations.form.reviewErrorBody': + 'The mock contract could not prepare the review. Keep the draft and try again.', + 'teams.automations.form.reviewErrorTitle': + 'Permission review needs attention', + 'teams.automations.form.reviewPermissions': 'Review permissions', + 'teams.automations.form.reviewPlaceholder': + 'Review is prepared after the draft cadence and target are ready.', 'teams.automations.form.save': '保存修改', 'teams.automations.form.scheduleReadsAs': '计划解读为', + 'teams.automations.form.section.permissionReview': + '4. Review Agent Key consent', + 'teams.automations.form.section.permissionReviewHint': + 'Browser login authorization only confirms this consent. Automation uses a dedicated Agent Key managed by Aevatar.', 'teams.automations.form.section.schedule': '3. 运行节奏', 'teams.automations.form.section.scheduleHint': '选择常用节奏,或切换到自定义 Cron 配置高级计划。', @@ -810,6 +842,7 @@ const zhCNMessages = { 'teams.automations.form.section.work': '2. 要执行的任务', 'teams.automations.form.section.workHint': '给自动化命名,可选填写每次触发时发送给成员的 Prompt。', + 'teams.automations.form.serviceGrants': 'Service grants', 'teams.automations.form.timezone': '时区', 'teams.automations.form.timezoneAria': '时区', 'teams.automations.form.title': '新建成员自动化', @@ -818,6 +851,10 @@ const zhCNMessages = { 'teams.automations.member.unknown': '未知成员', 'teams.automations.member.workflowOnly': '只有 Workflow 成员可以添加周期任务。', + 'teams.automations.messages.consentRequired': + 'Review and consent to the automation-dedicated Agent Key before creating.', + 'teams.automations.messages.createAccepted': + 'Automation creation request accepted.', 'teams.automations.messages.createFailed': '自动化未创建:{message}', 'teams.automations.messages.createSuccess': '自动化已创建。', 'teams.automations.messages.cronRequired': '请先填写 Cron 表达式。', @@ -827,6 +864,8 @@ const zhCNMessages = { 'teams.automations.messages.previewFailed': '预览失败:{message}', 'teams.automations.messages.promptTooLong': '周期 Prompt 最多 {maxLength} 个字符。', + 'teams.automations.messages.reviewFailed': + 'Permission review could not be prepared: {message}', 'teams.automations.messages.runNowFailed': '立即运行请求失败:{message}', 'teams.automations.messages.runNowSuccess': '已请求立即运行。', 'teams.automations.messages.serviceIdentityLoading': '服务身份仍在加载中。', diff --git a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx index 1c46dfab04..1b910d40dc 100644 --- a/apps/aevatar-console-web/src/pages/teams/detail.test.tsx +++ b/apps/aevatar-console-web/src/pages/teams/detail.test.tsx @@ -8,6 +8,7 @@ import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; import { runtimeGAgentApi } from "@/shared/api/runtimeGAgentApi"; import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; import { scheduledDispatchApi } from "@/shared/api/scheduledDispatchApi"; +import { teamAutomationApi } from "@/shared/api/teamAutomationApi"; import { formatCompactDateTime } from "@/shared/datetime/dateTime"; import { studioApi } from "@/shared/studio/api"; import { @@ -329,6 +330,72 @@ function mockCreateScheduledDispatchSummary(overrides?: Record) { }; } +function mockCreateTeamAutomationPermissionReview( + overrides?: Record, +) { + return { + status: "ready", + permissionDigest: "perm-digest-alpha-v1", + policyVersion: "agent-key-policy-v1", + credentialPlan: { + mode: "dedicated-per-schedule", + hostedBy: "Aevatar", + browserReceivesRawKey: false, + expiresAt: "2026-09-30T00:00:00Z", + }, + serviceGrants: [ + { + grantId: "service-chat-invoke", + targetId: "svc-alpha", + displayName: "Published service svc-alpha", + permission: "Invoke workflow chat endpoint", + }, + ], + nodeGrants: [ + { + grantId: "workflow-runtime-start", + targetId: "workflow-runtime", + displayName: "Workflow runtime", + permission: "Start scheduled workflow runs", + }, + ], + ...overrides, + }; +} + +function mockCreateTeamAutomationReceipt(overrides?: Record) { + return { + scheduleId: "sch-alpha", + scheduleActorId: "schedule-actor-alpha", + accepted: true, + commandId: "cmd-team-automation-alpha", + correlationId: "corr-team-automation-alpha", + ackedAt: "2026-06-10T08:35:00Z", + ackStage: "accepted", + permissionDigest: "perm-digest-alpha-v1", + policyVersion: "agent-key-policy-v1", + ...overrides, + }; +} + +const forbiddenAutomationSecretMarkers = [ + "fullKey", + "full_key", + "OAuth token", + "oauth token", + "SecretReference", + "apiKeyId", + "ak-alpha", +] as const; + +function expectNoAutomationSecretMarkers(value: unknown) { + const serialized = + typeof value === "string" ? value : JSON.stringify(value, null, 2); + for (const marker of forbiddenAutomationSecretMarkers) { + expect(serialized).not.toContain(marker); + } +} + function collectRenderedStyleText(): string { return Array.from(document.querySelectorAll("style")) .map((styleElement) => styleElement.textContent ?? "") @@ -675,6 +742,13 @@ jest.mock("@/shared/api/scheduledDispatchApi", () => ({ }, })); +jest.mock("@/shared/api/teamAutomationApi", () => ({ + teamAutomationApi: { + preflightCreate: jest.fn(async () => mockCreateTeamAutomationPermissionReview()), + create: jest.fn(async () => mockCreateTeamAutomationReceipt()), + }, +})); + jest.mock("@/shared/agui/sseFrameNormalizer", () => ({ parseBackendSSEStream: jest.fn(async function* () { yield { @@ -988,6 +1062,14 @@ describe("TeamDetailPage", () => { ackedAt: "2026-06-10T08:35:00Z", ackStage: "accepted", })); + (teamAutomationApi.preflightCreate as jest.Mock).mockReset(); + (teamAutomationApi.preflightCreate as jest.Mock).mockImplementation( + async () => mockCreateTeamAutomationPermissionReview(), + ); + (teamAutomationApi.create as jest.Mock).mockReset(); + (teamAutomationApi.create as jest.Mock).mockImplementation( + async () => mockCreateTeamAutomationReceipt(), + ); (scheduledDispatchApi.update as jest.Mock).mockReset(); (scheduledDispatchApi.update as jest.Mock).mockImplementation(async () => ({ scheduleId: "sch-alpha", @@ -2700,7 +2782,7 @@ describe("TeamDetailPage", () => { expect(screen.getAllByText("已触发").length).toBeGreaterThan(0); }); - it("creates member recurring work with the published service identity", async () => { + it("creates member recurring work after Agent Key permission review and consent", async () => { window.history.replaceState( {}, "", @@ -2718,12 +2800,15 @@ describe("TeamDetailPage", () => { totalCount: 1, }); - renderWithQueryClient(React.createElement(TeamDetailPage)); + const { queryClient } = renderWithQueryClient( + React.createElement(TeamDetailPage), + ); fireEvent.click(await screen.findByRole("button", { name: "添加周期任务" })); expect(await screen.findByText("1. 目标成员")).toBeTruthy(); expect(screen.getByText("2. 要执行的任务")).toBeTruthy(); expect(screen.getByText("3. 运行节奏")).toBeTruthy(); + expect(screen.getByText("4. Review Agent Key consent")).toBeTruthy(); const defaultTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; expect(screen.getByText(`工作日 09:00 · ${defaultTimezone}`)).toBeTruthy(); @@ -2741,36 +2826,78 @@ describe("TeamDetailPage", () => { target: { value: "Asia/Shanghai" }, }); expect(scheduledDispatchApi.listAll).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole("button", { name: "创建自动化" })); + fireEvent.click(screen.getByRole("button", { name: "Review permissions" })); await waitFor(() => { - expect(scheduledDispatchApi.create).toHaveBeenCalled(); + expect(teamAutomationApi.preflightCreate).toHaveBeenCalled(); }); - const payload = (scheduledDispatchApi.create as jest.Mock).mock.calls[0][0]; - expect(payload).toEqual( + const reviewPayload = (teamAutomationApi.preflightCreate as jest.Mock).mock + .calls[0][0]; + expect(reviewPayload).toEqual( expect.objectContaining({ + scopeId: "scope-1", + teamId: "t-alpha", + memberId: "member-team-alpha", + publishedServiceId: "alpha-service", + serviceRevisionId: "rev-2", displayName: "Daily escalation digest", cronExpression: "0 9 * * 1-5", timezone: "Asia/Shanghai", enabled: true, - headers: { - source: "team-automations", - }, - workflowChatTarget: { - identity: { - tenantId: "scope-1", - appId: "default", - namespace: "default", - serviceId: "alpha-service", - }, + prompt: "Summarize escalations and follow-up owners.", + }), + ); + expect(scheduledDispatchApi.create).not.toHaveBeenCalled(); + expect(screen.getByText("Credential mode · dedicated-per-schedule")).toBeTruthy(); + expect(screen.getByText("Aevatar managed")).toBeTruthy(); + expect( + screen.getByText("Browser never receives the raw Agent Key"), + ).toBeTruthy(); + expect(screen.getByText("Service grants")).toBeTruthy(); + expect(screen.getByText("Node grants")).toBeTruthy(); + expect(screen.getByText("Permission digest · perm-digest-alpha-v1")).toBeTruthy(); + const createButton = screen.getByRole("button", { + name: "Create with Agent Key consent", + }); + expect(createButton).toBeDisabled(); + fireEvent.click( + screen.getByLabelText(/I consent to Aevatar creating an automation-dedicated Agent Key/), + ); + expect(createButton).not.toBeDisabled(); + fireEvent.click(createButton); + + await waitFor(() => { + expect(teamAutomationApi.create).toHaveBeenCalled(); + }); + const createPayload = (teamAutomationApi.create as jest.Mock).mock.calls[0][0]; + expect(createPayload).toEqual( + expect.objectContaining({ + draft: expect.objectContaining({ + memberId: "member-team-alpha", + publishedServiceId: "alpha-service", + serviceRevisionId: "rev-2", + displayName: "Daily escalation digest", prompt: "Summarize escalations and follow-up owners.", - revisionId: "rev-2", - }, + }), + permissionDigest: "perm-digest-alpha-v1", + policyVersion: "agent-key-policy-v1", + consent: expect.objectContaining({ + accepted: true, + browserLoginConsent: true, + automationAgentKeyConsent: true, + }), }), ); - expect(JSON.stringify(payload)).not.toContain("member-team-alpha"); - expect(JSON.stringify(payload)).not.toContain("wf-team-alpha"); - expect(message.success).toHaveBeenCalledWith("自动化已创建。"); + expect(scheduledDispatchApi.create).not.toHaveBeenCalled(); + expectNoAutomationSecretMarkers(reviewPayload); + expectNoAutomationSecretMarkers(createPayload); + expectNoAutomationSecretMarkers( + queryClient.getQueryCache().getAll().map((query) => query.state.data), + ); + expectNoAutomationSecretMarkers(document.body.textContent ?? ""); + expect(message.success).toHaveBeenCalledWith( + "Automation creation request accepted.", + ); expect(scheduledDispatchApi.listAll).toHaveBeenCalledTimes(1); await waitFor(() => { expect(screen.getAllByText("Daily escalation digest").length).toBeGreaterThan( @@ -2792,6 +2919,59 @@ describe("TeamDetailPage", () => { }); }); + it("does not submit when permission review closes before consent and keeps the draft", async () => { + window.history.replaceState( + {}, + "", + "/scopes/scope-1/teams/t-alpha?memberId=member-team-alpha&tab=automations", + ); + + renderWithQueryClient(React.createElement(TeamDetailPage)); + + fireEvent.click(await screen.findByRole("button", { name: "添加周期任务" })); + const dialog = await screen.findByRole("dialog", { + name: "新建成员自动化", + }); + fireEvent.change(within(dialog).getByLabelText("自动化名称"), { + target: { value: "Draft escalation digest" }, + }); + fireEvent.change(within(dialog).getByLabelText(/周期 Prompt/), { + target: { value: "Keep this draft after review closes." }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Review permissions" })); + + expect(await within(dialog).findByText("Credential mode · dedicated-per-schedule")).toBeTruthy(); + expect( + within(dialog).getByRole("button", { + name: "Create with Agent Key consent", + }), + ).toBeDisabled(); + const closeDraftButton = within(dialog) + .getAllByRole("button", { name: "Close" }) + .find((button) => button.textContent?.trim() === "Close"); + expect(closeDraftButton).toBeTruthy(); + fireEvent.click(closeDraftButton as HTMLElement); + + expect(teamAutomationApi.create).not.toHaveBeenCalled(); + expect(scheduledDispatchApi.create).not.toHaveBeenCalled(); + + fireEvent.click(await screen.findByRole("button", { name: "添加周期任务" })); + const reopenedDialog = await screen.findByRole("dialog", { + name: "新建成员自动化", + }); + expect(within(reopenedDialog).getByLabelText("自动化名称")).toHaveValue( + "Draft escalation digest", + ); + expect(within(reopenedDialog).getByLabelText(/周期 Prompt/)).toHaveValue( + "Keep this draft after review closes.", + ); + expect( + within(reopenedDialog).getByText( + "Review is prepared after the draft cadence and target are ready.", + ), + ).toBeTruthy(); + }); + it("previews next runs from the automation form", async () => { window.history.replaceState( {}, @@ -2854,28 +3034,35 @@ describe("TeamDetailPage", () => { fireEvent.change(within(dialog).getByLabelText("自动化名称"), { target: { value: "Daily escalation digest" }, }); - fireEvent.click(within(dialog).getByRole("button", { name: "创建自动化" })); + fireEvent.click(within(dialog).getByRole("button", { name: "Review permissions" })); await waitFor(() => { - expect(scheduledDispatchApi.create).toHaveBeenCalled(); + expect(teamAutomationApi.preflightCreate).toHaveBeenCalled(); }); - expect((scheduledDispatchApi.create as jest.Mock).mock.calls[0][0]).toEqual( + expect((teamAutomationApi.preflightCreate as jest.Mock).mock.calls[0][0]).toEqual( expect.objectContaining({ displayName: "Daily escalation digest", - workflowChatTarget: { - identity: { - tenantId: "scope-1", - appId: "default", - namespace: "default", - serviceId: "alpha-service", - }, - prompt: "", - revisionId: "rev-2", - }, + prompt: "", + publishedServiceId: "alpha-service", + serviceRevisionId: "rev-2", }), ); + fireEvent.click( + within(dialog).getByLabelText(/I consent to Aevatar creating an automation-dedicated Agent Key/), + ); + fireEvent.click( + within(dialog).getByRole("button", { + name: "Create with Agent Key consent", + }), + ); + await waitFor(() => { + expect(teamAutomationApi.create).toHaveBeenCalled(); + }); + expect(scheduledDispatchApi.create).not.toHaveBeenCalled(); expect(message.error).not.toHaveBeenCalledWith("保存前请先描述周期任务。"); - expect(message.success).toHaveBeenCalledWith("自动化已创建。"); + expect(message.success).toHaveBeenCalledWith( + "Automation creation request accepted.", + ); }); it("creates member recurring work with the default service revision when the active serving revision is missing", async () => { @@ -2907,18 +3094,14 @@ describe("TeamDetailPage", () => { fireEvent.change(screen.getByLabelText(/周期 Prompt/), { target: { value: "Summarize escalations and follow-up owners." }, }); - fireEvent.click(screen.getByRole("button", { name: "创建自动化" })); + fireEvent.click(screen.getByRole("button", { name: "Review permissions" })); await waitFor(() => { - expect(scheduledDispatchApi.create).toHaveBeenCalled(); + expect(teamAutomationApi.preflightCreate).toHaveBeenCalled(); }); - const payload = (scheduledDispatchApi.create as jest.Mock).mock.calls[0][0]; - expect(payload.workflowChatTarget).toEqual( - expect.objectContaining({ - revisionId: "rev-2", - }), - ); - expect(message.success).toHaveBeenCalledWith("自动化已创建。"); + const payload = (teamAutomationApi.preflightCreate as jest.Mock).mock.calls[0][0]; + expect(payload).toEqual(expect.objectContaining({ serviceRevisionId: "rev-2" })); + expect(scheduledDispatchApi.create).not.toHaveBeenCalled(); }); it("creates member recurring work without a revision when no serving revision is available", async () => { @@ -2948,34 +3131,56 @@ describe("TeamDetailPage", () => { fireEvent.change(await screen.findByLabelText(/周期 Prompt/), { target: { value: "Summarize escalations and follow-up owners." }, }); - fireEvent.click(screen.getByRole("button", { name: "创建自动化" })); + fireEvent.click(screen.getByRole("button", { name: "Review permissions" })); await waitFor(() => { - expect(scheduledDispatchApi.create).toHaveBeenCalled(); + expect(teamAutomationApi.preflightCreate).toHaveBeenCalled(); }); - const payload = (scheduledDispatchApi.create as jest.Mock).mock.calls[0][0]; - expect(payload.workflowChatTarget).toEqual({ - identity: { - tenantId: "scope-1", - appId: "default", - namespace: "default", - serviceId: "alpha-service", - }, - prompt: "Summarize escalations and follow-up owners.", + const payload = (teamAutomationApi.preflightCreate as jest.Mock).mock.calls[0][0]; + expect(payload).toEqual( + expect.objectContaining({ + publishedServiceId: "alpha-service", + prompt: "Summarize escalations and follow-up owners.", + }), + ); + expect(payload).not.toEqual( + expect.objectContaining({ + serviceRevisionId: expect.any(String), + }), + ); + fireEvent.click( + screen.getByLabelText(/I consent to Aevatar creating an automation-dedicated Agent Key/), + ); + fireEvent.click( + screen.getByRole("button", { name: "Create with Agent Key consent" }), + ); + await waitFor(() => { + expect(teamAutomationApi.create).toHaveBeenCalledWith({ + draft: expect.objectContaining({ + publishedServiceId: "alpha-service", + prompt: "Summarize escalations and follow-up owners.", + }), + permissionDigest: "perm-digest-alpha-v1", + policyVersion: "agent-key-policy-v1", + consent: expect.objectContaining({ + accepted: true, + }), + }); }); - expect(message.success).toHaveBeenCalledWith("自动化已创建。"); + expect(scheduledDispatchApi.create).not.toHaveBeenCalled(); + expect(message.success).toHaveBeenCalledWith( + "Automation creation request accepted.", + ); }); - it("keeps schedule create failures inside the automations flow", async () => { + it("keeps Team Automation create failures inside the automations flow", async () => { window.history.replaceState( {}, "", "/scopes/scope-1/teams/t-alpha?memberId=member-team-alpha&tab=automations", ); - (scheduledDispatchApi.create as jest.Mock).mockRejectedValueOnce( - new Error( - "One or more validation errors occurred.: $.serviceInvocation.payload.value: The JSON value could not be converted to Google.Protobuf.ByteString.", - ), + (teamAutomationApi.create as jest.Mock).mockRejectedValueOnce( + new Error("Mock Team Automation create failed."), ); renderWithQueryClient(React.createElement(TeamDetailPage)); @@ -2984,14 +3189,24 @@ describe("TeamDetailPage", () => { fireEvent.change(await screen.findByLabelText(/周期 Prompt/), { target: { value: "Summarize escalations and follow-up owners." }, }); - fireEvent.click(screen.getByRole("button", { name: "创建自动化" })); + fireEvent.click(screen.getByRole("button", { name: "Review permissions" })); + await screen.findByText("Credential mode · dedicated-per-schedule"); + fireEvent.click( + screen.getByLabelText(/I consent to Aevatar creating an automation-dedicated Agent Key/), + ); + fireEvent.click( + screen.getByRole("button", { name: "Create with Agent Key consent" }), + ); await waitFor(() => { expect(message.error).toHaveBeenCalledWith( - "自动化未创建:One or more validation errors occurred.: $.serviceInvocation.payload.value: The JSON value could not be converted to Google.Protobuf.ByteString.", + "自动化未创建:Mock Team Automation create failed.", ); }); - expect(message.success).not.toHaveBeenCalledWith("自动化已创建。"); + expect(scheduledDispatchApi.create).not.toHaveBeenCalled(); + expect(message.success).not.toHaveBeenCalledWith( + "Automation creation request accepted.", + ); }); it("edits member recurring work with the published service identity", async () => { diff --git a/apps/aevatar-console-web/src/pages/teams/tabs/TeamAutomationsTab.tsx b/apps/aevatar-console-web/src/pages/teams/tabs/TeamAutomationsTab.tsx index c2f419a913..54f426dcb0 100644 --- a/apps/aevatar-console-web/src/pages/teams/tabs/TeamAutomationsTab.tsx +++ b/apps/aevatar-console-web/src/pages/teams/tabs/TeamAutomationsTab.tsx @@ -13,6 +13,7 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button, + Checkbox, Input, Modal, Segmented, @@ -37,6 +38,11 @@ import { type ScheduledDispatchRunNowReceipt, type ScheduledDispatchSummary, } from "@/shared/api/scheduledDispatchApi"; +import { + teamAutomationApi, + type TeamAutomationCreateDraft, + type TeamAutomationPermissionReview, +} from "@/shared/api/teamAutomationApi"; import { formatCompactDateTime } from "@/shared/datetime/dateTime"; import type { ServiceIdentity } from "@/shared/models/services"; import { history } from "@/shared/navigation/history"; @@ -86,6 +92,15 @@ type AutomationFormState = { readonly timezone: string; }; +type TeamAutomationCreateStage = + | "draft" + | "preflight" + | "permissionReview" + | "consent" + | "pending" + | "planChanged" + | "error"; + type ManualRunFeedback = Pick< ScheduledDispatchRunNowReceipt, "ackedAt" | "commandId" | "correlationId" | "scheduledFireAt" @@ -101,6 +116,30 @@ const customPreset = "custom"; const defaultPreset = "weekdays-0900"; const defaultCronExpression = "0 9 * * 1-5"; +function buildDefaultAutomationFormState(memberId = ""): AutomationFormState { + return { + cronExpression: defaultCronExpression, + displayName: "", + enabled: true, + memberId, + preset: defaultPreset, + prompt: "", + timezone: resolveDefaultTimezone(), + }; +} + +function hasAutomationDraft(formState: AutomationFormState): boolean { + return Boolean( + formState.displayName.trim() || + formState.prompt.trim() || + formState.memberId.trim() || + formState.cronExpression.trim() !== defaultCronExpression || + formState.preset !== defaultPreset || + formState.timezone.trim() !== resolveDefaultTimezone() || + !formState.enabled, + ); +} + const pageGridStyle: React.CSSProperties = { alignItems: "start", display: "grid", @@ -716,15 +755,18 @@ const TeamAutomationsTab: React.FC = ({ const highlightScheduleRef = React.useRef | null>( null, ); - const [formState, setFormState] = React.useState(() => ({ - cronExpression: defaultCronExpression, - displayName: "", - enabled: true, - memberId: "", - preset: defaultPreset, - prompt: "", - timezone: resolveDefaultTimezone(), - })); + const [formState, setFormState] = React.useState(() => + buildDefaultAutomationFormState(), + ); + const [createStage, setCreateStage] = + React.useState("draft"); + const [permissionReview, setPermissionReview] = + React.useState(null); + const [agentKeyConsentChecked, setAgentKeyConsentChecked] = + React.useState(false); + const [createReviewError, setCreateReviewError] = React.useState(""); + const [hasPreservedCreateDraft, setHasPreservedCreateDraft] = + React.useState(false); const scheduleQueryKey = React.useMemo( () => ["scheduled-dispatches", "team", scopeId, teamId] as const, [scopeId, teamId], @@ -1044,11 +1086,11 @@ const TeamAutomationsTab: React.FC = ({ ); const showCreatedScheduleFeedback = React.useCallback( ({ - input, + draft, member, receipt, }: { - readonly input: ScheduledDispatchConfigurationInput; + readonly draft: TeamAutomationCreateDraft; readonly member: TeamAutomationMemberRow; readonly receipt: ScheduledDispatchMutationReceipt; }) => { @@ -1061,14 +1103,14 @@ const TeamAutomationsTab: React.FC = ({ const now = new Date().toISOString(); const pendingSchedule: ScheduledDispatchSummary = { scheduleId, - displayName: trimText(input.displayName), + displayName: trimText(draft.displayName), targetKind: "service_invocation", targetActorId: "", payloadTypeUrl: "", serviceKey: [ - input.workflowChatTarget.identity.tenantId, - input.workflowChatTarget.identity.appId, - input.workflowChatTarget.identity.namespace, + member.serviceIdentity?.tenantId ?? draft.scopeId, + member.serviceIdentity?.appId ?? "default", + member.serviceIdentity?.namespace ?? "default", serviceId, ] .map(trimText) @@ -1076,10 +1118,10 @@ const TeamAutomationsTab: React.FC = ({ .join(":"), serviceId, serviceEndpointId: "chat", - prompt: trimText(input.workflowChatTarget.prompt), - cronExpression: input.cronExpression, - timezone: trimText(input.timezone) || resolveDefaultTimezone(), - enabled: input.enabled ?? true, + prompt: trimText(draft.prompt), + cronExpression: draft.cronExpression, + timezone: trimText(draft.timezone) || resolveDefaultTimezone(), + enabled: draft.enabled, createdAt: now, updatedAt: now, nextFireAt: null, @@ -1090,7 +1132,7 @@ const TeamAutomationsTab: React.FC = ({ lastError: "", fireCount: 0, failureCount: 0, - headers: { ...(input.headers ?? {}) }, + headers: { source: "team-automations" }, scheduleActorId: receipt.scheduleActorId, scheduleKind: "workflow", deleted: false, @@ -1142,20 +1184,65 @@ const TeamAutomationsTab: React.FC = ({ setPreview(result); }, }); + const permissionReviewMutation = useMutation({ + mutationFn: ({ + draft, + }: { + readonly draft: TeamAutomationCreateDraft; + readonly member: TeamAutomationMemberRow; + }) => teamAutomationApi.preflightCreate(draft), + onError: (error) => { + const detail = error instanceof Error ? error.message : String(error); + setCreateStage("error"); + setCreateReviewError(detail); + void message.error( + intl.formatMessage( + { + id: "teams.automations.messages.reviewFailed", + defaultMessage: "Permission review could not be prepared: {message}", + }, + { message: detail }, + ), + ); + }, + onMutate: () => { + setAgentKeyConsentChecked(false); + setCreateReviewError(""); + setCreateStage("preflight"); + }, + onSuccess: (review) => { + setPermissionReview(review); + setCreateStage(review.status === "plan-changed" ? "planChanged" : "permissionReview"); + }, + }); const createMutation = useMutation({ mutationFn: ({ - input, + draft, member, + review, }: { - readonly input: ScheduledDispatchConfigurationInput; + readonly draft: TeamAutomationCreateDraft; readonly member: TeamAutomationMemberRow; + readonly review: TeamAutomationPermissionReview; }) => - scheduledDispatchApi.create(input).then((receipt) => ({ - input, + teamAutomationApi.create({ + draft, + permissionDigest: review.permissionDigest, + policyVersion: review.policyVersion, + consent: { + accepted: true, + acceptedAt: new Date().toISOString(), + browserLoginConsent: true, + automationAgentKeyConsent: true, + }, + }).then((receipt) => ({ + draft, member, receipt, })), onError: (error) => { + setCreateStage("error"); + setCreateReviewError(error instanceof Error ? error.message : String(error)); void message.error( intl.formatMessage( { @@ -1166,16 +1253,25 @@ const TeamAutomationsTab: React.FC = ({ ), ); }, - onSuccess: ({ input, member, receipt }) => { + onMutate: () => { + setCreateStage("pending"); + setCreateReviewError(""); + }, + onSuccess: ({ draft, member, receipt }) => { void message.success( intl.formatMessage({ - id: "teams.automations.messages.createSuccess", - defaultMessage: "Automation created.", + id: "teams.automations.messages.createAccepted", + defaultMessage: "Automation creation request accepted.", }), ); - showCreatedScheduleFeedback({ input, member, receipt }); + showCreatedScheduleFeedback({ draft, member, receipt }); setCreateOpen(false); setPreview(null); + setPermissionReview(null); + setAgentKeyConsentChecked(false); + setCreateStage("draft"); + setHasPreservedCreateDraft(false); + setFormState(buildDefaultAutomationFormState(selectedMember?.memberId ?? "")); scheduleDelayedRefresh(); }, }); @@ -1290,18 +1386,18 @@ const TeamAutomationsTab: React.FC = ({ const openCreate = React.useCallback(() => { const member = selectedMember; setEditingSchedule(null); - setFormState({ - cronExpression: defaultCronExpression, - displayName: "", - enabled: true, - memberId: member?.memberId ?? "", - preset: defaultPreset, - prompt: "", - timezone: resolveDefaultTimezone(), - }); + setFormState((current) => + hasPreservedCreateDraft + ? current + : buildDefaultAutomationFormState(member?.memberId ?? ""), + ); setPreview(null); + setPermissionReview(null); + setAgentKeyConsentChecked(false); + setCreateReviewError(""); + setCreateStage("draft"); setCreateOpen(true); - }, [selectedMember]); + }, [hasPreservedCreateDraft, selectedMember]); const openEdit = React.useCallback( (schedule: ScheduledDispatchSummary) => { @@ -1312,6 +1408,7 @@ const TeamAutomationsTab: React.FC = ({ cronPresets.find((item) => item.cronExpression === cronExpression)?.value ?? customPreset; setEditingSchedule(schedule); + setHasPreservedCreateDraft(false); setFormState({ cronExpression, displayName: trimText(schedule.displayName), @@ -1322,6 +1419,10 @@ const TeamAutomationsTab: React.FC = ({ timezone: trimText(schedule.timezone) || resolveDefaultTimezone(), }); setPreview(null); + setPermissionReview(null); + setAgentKeyConsentChecked(false); + setCreateReviewError(""); + setCreateStage("draft"); setCreateOpen(true); }, [cronPresets, findMemberForSchedule, selectedMember], @@ -1354,8 +1455,15 @@ const TeamAutomationsTab: React.FC = ({ ...patch, })); setPreview(null); + setPermissionReview(null); + setAgentKeyConsentChecked(false); + setCreateReviewError(""); + setCreateStage("draft"); + if (!isEditingAutomation) { + setHasPreservedCreateDraft(true); + } }, - [], + [isEditingAutomation], ); const previewNextRuns = React.useCallback(async () => { @@ -1389,6 +1497,10 @@ const TeamAutomationsTab: React.FC = ({ }, [previewNextRuns]); const saveAutomation = React.useCallback(async () => { + if (permissionReviewMutation.isPending || createMutation.isPending) { + return; + } + const member = activeFormMember; const serviceIdentity = member?.serviceIdentity; const serviceRevisionId = trimText(member?.serviceRevisionId); @@ -1431,18 +1543,20 @@ const TeamAutomationsTab: React.FC = ({ ); return; } + const displayName = + formState.displayName.trim() || + intl.formatMessage( + { + id: "teams.automations.form.defaultTitle", + defaultMessage: "{memberName} recurring work", + }, + { memberName: member.name }, + ); + const timezone = trimText(formState.timezone) || undefined; const input: ScheduledDispatchConfigurationInput = { - displayName: - formState.displayName.trim() || - intl.formatMessage( - { - id: "teams.automations.form.defaultTitle", - defaultMessage: "{memberName} recurring work", - }, - { memberName: member.name }, - ), + displayName, cronExpression, - timezone: trimText(formState.timezone) || undefined, + timezone, enabled: formState.enabled, headers: { source: "team-automations", @@ -1462,9 +1576,46 @@ const TeamAutomationsTab: React.FC = ({ return; } - await createMutation.mutateAsync({ input, member }); + const draft: TeamAutomationCreateDraft = { + scopeId, + teamId, + memberId: member.memberId, + publishedServiceId: + trimText(member.serviceIdentity?.serviceId) || trimText(member.serviceId), + serviceRevisionId: serviceRevisionId || undefined, + displayName, + prompt, + cronExpression, + timezone, + enabled: formState.enabled, + }; + + if ( + !permissionReview || + createStage === "draft" || + createStage === "error" || + createStage === "planChanged" + ) { + await permissionReviewMutation.mutateAsync({ draft, member }); + return; + } + + if (!agentKeyConsentChecked || createStage !== "consent") { + void message.error( + intl.formatMessage({ + id: "teams.automations.messages.consentRequired", + defaultMessage: + "Review and consent to the automation-dedicated Agent Key before creating.", + }), + ); + return; + } + + await createMutation.mutateAsync({ draft, member, review: permissionReview }); }, [ activeFormMember, + agentKeyConsentChecked, + createStage, createMutation, editingScheduleId, formState.cronExpression, @@ -1474,8 +1625,11 @@ const TeamAutomationsTab: React.FC = ({ formState.timezone, intl, isEditingAutomation, + permissionReview, + permissionReviewMutation, + scopeId, serviceIdentitiesLoading, - showCreatedScheduleFeedback, + teamId, updateMutation, ]); @@ -1915,7 +2069,7 @@ const TeamAutomationsTab: React.FC = ({ : schedule.displayName; return ( -
= ({ onClick: () => deleteMutation.mutate(scheduleId), })}
- + ); })} @@ -2095,7 +2249,12 @@ const TeamAutomationsTab: React.FC = ({ intl, ); const canCreateAutomation = Boolean(activeFormMember?.serviceIdentity); - const formSubmitting = createMutation.isPending || updateMutation.isPending; + const formSubmitting = + createMutation.isPending || + updateMutation.isPending || + permissionReviewMutation.isPending; + const waitingForAgentKeyConsent = + !isEditingAutomation && createStage === "permissionReview"; const formTitle = isEditingAutomation ? intl.formatMessage({ id: "teams.automations.form.editTitle", @@ -2110,10 +2269,27 @@ const TeamAutomationsTab: React.FC = ({ id: "teams.automations.form.save", defaultMessage: "Save changes", }) - : intl.formatMessage({ - id: "teams.automations.form.create", - defaultMessage: "Create automation", - }); + : createStage === "preflight" + ? intl.formatMessage({ + id: "teams.automations.form.preparingReview", + defaultMessage: "Preparing review", + }) + : createStage === "permissionReview" || + createStage === "consent" || + createStage === "pending" + ? intl.formatMessage({ + id: "teams.automations.form.createWithConsent", + defaultMessage: "Create with Agent Key consent", + }) + : createStage === "planChanged" + ? intl.formatMessage({ + id: "teams.automations.form.refreshReview", + defaultMessage: "Refresh review", + }) + : intl.formatMessage({ + id: "teams.automations.form.reviewPermissions", + defaultMessage: "Review permissions", + }); return (
@@ -2312,20 +2488,38 @@ const TeamAutomationsTab: React.FC = ({
{ if (!formSubmitting) { + if (isEditingAutomation) { + setHasPreservedCreateDraft(false); + setFormState( + buildDefaultAutomationFormState(selectedMember?.memberId ?? ""), + ); + } else { + setHasPreservedCreateDraft(hasAutomationDraft(formState)); + } setCreateOpen(false); setEditingSchedule(null); setPreview(null); + setPermissionReview(null); + setAgentKeyConsentChecked(false); + setCreateReviewError(""); + setCreateStage("draft"); } }} onOk={handleSaveAutomation} @@ -2682,6 +2876,241 @@ const TeamAutomationsTab: React.FC = ({ )} + + {!isEditingAutomation ? ( +
+
+ + {intl.formatMessage({ + id: "teams.automations.form.section.permissionReview", + defaultMessage: "4. Review Agent Key consent", + })} + + + {intl.formatMessage({ + id: "teams.automations.form.section.permissionReviewHint", + defaultMessage: + "Browser login authorization only confirms this consent. Automation uses a dedicated Agent Key managed by Aevatar.", + })} + +
+ + {createStage === "preflight" ? ( +
+ +
+ ) : null} + + {createStage === "error" ? ( +
+ + {intl.formatMessage({ + id: "teams.automations.form.reviewErrorTitle", + defaultMessage: "Permission review needs attention", + })} + + + {createReviewError || + intl.formatMessage({ + id: "teams.automations.form.reviewErrorBody", + defaultMessage: + "The mock contract could not prepare the review. Keep the draft and try again.", + })} + +
+ ) : null} + + {permissionReview ? ( +
+ {createStage === "planChanged" ? ( +
+ + {permissionReview.warning || + intl.formatMessage({ + id: "teams.automations.form.planChanged", + defaultMessage: + "The authorization plan changed. Refresh the review before creating.", + })} + +
+ ) : null} + +
+
+ + {intl.formatMessage({ + id: "teams.automations.form.agentKeyPlan", + defaultMessage: "Automation dedicated Agent Key", + })} + + + + + + + +
+ +
+
+ + {intl.formatMessage({ + id: "teams.automations.form.serviceGrants", + defaultMessage: "Service grants", + })} + + {permissionReview.serviceGrants.map((grant) => ( + + {grant.displayName} · {grant.permission} + + ))} +
+
+ + {intl.formatMessage({ + id: "teams.automations.form.nodeGrants", + defaultMessage: "Node grants", + })} + + {permissionReview.nodeGrants.map((grant) => ( + + {grant.displayName} · {grant.permission} + + ))} +
+
+
+ + {createStage !== "planChanged" ? ( + { + const checked = event.target.checked; + setAgentKeyConsentChecked(checked); + setCreateStage(checked ? "consent" : "permissionReview"); + }} + > + {intl.formatMessage({ + id: "teams.automations.form.agentKeyConsent", + defaultMessage: + "I consent to Aevatar creating an automation-dedicated Agent Key for this schedule.", + })} + + ) : null} +
+ ) : createStage !== "preflight" && createStage !== "error" ? ( + + {intl.formatMessage({ + id: "teams.automations.form.reviewPlaceholder", + defaultMessage: + "Review is prepared after the draft cadence and target are ready.", + })} + + ) : null} +
+ ) : null}
diff --git a/apps/aevatar-console-web/src/shared/api/teamAutomationApi.ts b/apps/aevatar-console-web/src/shared/api/teamAutomationApi.ts new file mode 100644 index 0000000000..ededa63234 --- /dev/null +++ b/apps/aevatar-console-web/src/shared/api/teamAutomationApi.ts @@ -0,0 +1,144 @@ +export type TeamAutomationCreateDraft = { + readonly scopeId: string; + readonly teamId: string; + readonly memberId: string; + readonly publishedServiceId: string; + readonly serviceRevisionId?: string; + readonly displayName: string; + readonly prompt: string; + readonly cronExpression: string; + readonly timezone?: string; + readonly enabled: boolean; +}; + +export type TeamAutomationGrant = { + readonly grantId: string; + readonly targetId: string; + readonly displayName: string; + readonly permission: string; +}; + +export type TeamAutomationCredentialPlan = { + readonly mode: "dedicated-per-schedule"; + readonly hostedBy: "Aevatar"; + readonly browserReceivesRawKey: false; + readonly expiresAt: string; +}; + +export type TeamAutomationPermissionReview = { + readonly status: "ready" | "plan-changed"; + readonly permissionDigest: string; + readonly policyVersion: string; + readonly credentialPlan: TeamAutomationCredentialPlan; + readonly serviceGrants: readonly TeamAutomationGrant[]; + readonly nodeGrants: readonly TeamAutomationGrant[]; + readonly warning?: string; +}; + +export type TeamAutomationConsent = { + readonly accepted: boolean; + readonly acceptedAt: string; + readonly browserLoginConsent: boolean; + readonly automationAgentKeyConsent: boolean; +}; + +export type TeamAutomationCreateInput = { + readonly draft: TeamAutomationCreateDraft; + readonly permissionDigest: string; + readonly policyVersion: string; + readonly consent: TeamAutomationConsent; +}; + +export type TeamAutomationCreateReceipt = { + readonly scheduleId: string; + readonly scheduleActorId: string; + readonly accepted: boolean; + readonly commandId: string; + readonly correlationId: string; + readonly ackedAt: string; + readonly ackStage: "accepted"; + readonly permissionDigest: string; + readonly policyVersion: string; +}; + +export const teamAutomationMockFixtureIds = { + memberId: "m-alpha", + workflowId: "wf-alpha", + publishedServiceId: "svc-alpha", + scheduleId: "sch-alpha", + credentialFixtureId: "ak-alpha", +} as const; + +function trimText(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +export function buildMockTeamAutomationPermissionReview( + draft: TeamAutomationCreateDraft, +): TeamAutomationPermissionReview { + const publishedServiceId = + trimText(draft.publishedServiceId) || + teamAutomationMockFixtureIds.publishedServiceId; + const serviceRevisionId = trimText(draft.serviceRevisionId); + + return { + status: "ready", + permissionDigest: "perm-digest-alpha-v1", + policyVersion: "agent-key-policy-v1", + credentialPlan: { + mode: "dedicated-per-schedule", + hostedBy: "Aevatar", + browserReceivesRawKey: false, + expiresAt: "2026-09-30T00:00:00Z", + }, + serviceGrants: [ + { + grantId: "service-chat-invoke", + targetId: publishedServiceId, + displayName: serviceRevisionId + ? `Published service ${publishedServiceId} at ${serviceRevisionId}` + : `Published service ${publishedServiceId}`, + permission: "Invoke workflow chat endpoint", + }, + ], + nodeGrants: [ + { + grantId: "workflow-runtime-start", + targetId: "workflow-runtime", + displayName: "Workflow runtime", + permission: "Start scheduled workflow runs", + }, + ], + }; +} + +async function preflightCreateTeamAutomation( + draft: TeamAutomationCreateDraft, +): Promise { + return buildMockTeamAutomationPermissionReview(draft); +} + +async function createTeamAutomation( + input: TeamAutomationCreateInput, +): Promise { + if (!input.consent.accepted) { + throw new Error("Team Automation Agent Key consent is required."); + } + + return { + scheduleId: teamAutomationMockFixtureIds.scheduleId, + scheduleActorId: "schedule-actor-alpha", + accepted: true, + commandId: "cmd-team-automation-alpha", + correlationId: "corr-team-automation-alpha", + ackedAt: "2026-06-10T08:35:00Z", + ackStage: "accepted", + permissionDigest: input.permissionDigest, + policyVersion: input.policyVersion, + }; +} + +export const teamAutomationApi = { + preflightCreate: preflightCreateTeamAutomation, + create: createTeamAutomation, +};