diff --git a/.github/workflows/validate-configurator.yml b/.github/workflows/validate-configurator.yml index d92b0e7..a934786 100644 --- a/.github/workflows/validate-configurator.yml +++ b/.github/workflows/validate-configurator.yml @@ -64,7 +64,8 @@ jobs: application: runs-on: ubuntu-latest - timeout-minutes: 15 + # Browser OS dependencies can exhaust 15 minutes before acceptance starts. + timeout-minutes: 25 services: postgres: image: postgres@sha256:d74eeac9a635390a49bc21bd49fccd973de707e2a53a76ac49b552b8712ec46f diff --git a/landing-zone-configurator/app/apps/web/src/locales/en.ts b/landing-zone-configurator/app/apps/web/src/locales/en.ts index 951da1d..d34ee59 100644 --- a/landing-zone-configurator/app/apps/web/src/locales/en.ts +++ b/landing-zone-configurator/app/apps/web/src/locales/en.ts @@ -1,4 +1,7 @@ export const englishMessages: Record = { + "Assistant für Template-Entwürfe": "Assistant for template drafts", + "Der Assistant verwendet die geöffnete Plattformkonfiguration einschließlich ihrer Template-Entwürfe. Nenne das gewünschte Template und prüfe die vorgeschlagenen Änderungen vor der Übernahme. Speichere sie anschließend ausdrücklich; eine Veröffentlichung erfolgt separat.": + "The Assistant uses the open platform configuration, including its template drafts. Name the template you want to change and review the proposed changes before accepting them. Then save explicitly; publication is a separate step.", "Für diesen Plattformvertrag fehlt die Ausführungsbindung. Richte sie unter Plattformanbindung mit dem passenden State-Backend ein.": "This platform contract has no execution binding. Configure it under platform binding with the appropriate state backend.", "Konfiguration bearbeiten": "Edit configuration", diff --git a/landing-zone-configurator/app/apps/web/src/main.tsx b/landing-zone-configurator/app/apps/web/src/main.tsx index d78e73c..bb3e68c 100644 --- a/landing-zone-configurator/app/apps/web/src/main.tsx +++ b/landing-zone-configurator/app/apps/web/src/main.tsx @@ -371,6 +371,25 @@ function App() { "editor", ].includes(view); + const assistantPanel = + platformAccess && draft && session ? ( + { + setDraft(loaded); + setDraftEpoch((value) => value + 1); + setConfigurationBinding(binding); + setSavedDraft(JSON.stringify(persisted)); + setDeploymentSelection(null); + }} + /> + ) : null; + return ( <> @@ -761,6 +780,13 @@ function App() { isCommonDraft(draft) && draft.projectTemplates !== undefined ? ( <> +

{t("Assistant für Template-Entwürfe")}

+

+ {t( + "Der Assistant verwendet die geöffnete Plattformkonfiguration einschließlich ihrer Template-Entwürfe. Nenne das gewünschte Template und prüfe die vorgeschlagenen Änderungen vor der Übernahme. Speichere sie anschließend ausdrücklich; eine Veröffentlichung erfolgt separat.", + )} +

+ {assistantPanel} - {view === "editor" && draft && session && ( - { - setDraft(loaded); - setDraftEpoch((value) => value + 1); - setConfigurationBinding(binding); - setSavedDraft(JSON.stringify(persisted)); - setDeploymentSelection(null); - }} - /> - )} + {view === "editor" && assistantPanel} {view === "editor" && !draft && (

{t("Kein Entwurf in diesem Tab")}

diff --git a/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts b/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts index 3f1b58d..d5867e1 100644 --- a/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts +++ b/landing-zone-configurator/app/e2e/platform-owner-flow.spec.ts @@ -1,3 +1,4 @@ +import { assistantProposalFields } from "@lzc/contracts"; import { type CommonConfiguration, createEditorConfiguration, @@ -10,12 +11,13 @@ const userId = "11111111-1111-4111-8111-111111111111"; const tenantId = "22222222-2222-4222-8222-222222222222"; const organizationId = "33333333-3333-4333-8333-333333333333"; const configurationId = "44444444-4444-4444-8444-444444444444"; +const documentId = "66666666-6666-4666-8666-666666666666"; const contractRevision = "55555555-5555-4555-8555-555555555555"; function platformConfiguration(): CommonConfiguration { return editCommonInput( { - ...createEditorConfiguration("standalone", configurationId), + ...createEditorConfiguration("standalone", documentId), name: "Existing platform workflow", projectTemplates: [structuredClone(legacyAccessTemplate)], }, @@ -299,6 +301,9 @@ test("Application Owner receives the catalogue without platform editor, binding await expect( page.getByLabel("Name des Templates", { exact: true }), ).toHaveCount(0); + await expect( + page.getByRole("region", { name: "Konfigurations-Chat", exact: true }), + ).toHaveCount(0); expect(state.privateReads).toEqual([]); expect(state.writes).toEqual([]); expect(state.executionWrites).toEqual([]); @@ -472,3 +477,182 @@ test("configuration reopen protects edits made during its read and skips confirm expect(state.writes).toEqual([]); expect(state.executionWrites).toEqual([]); }); + +test("template-draft Assistant uses the saved configuration and accepts locally before explicit save", async ({ + page, +}) => { + const state = await mockWorkspace(page); + const original = platformConfiguration(); + expect(original.id).not.toBe(configurationId); + const template = original.projectTemplates?.[0]; + if (!template) throw new Error("Expected template"); + const candidate = structuredClone(original); + const proposedTemplate = candidate.projectTemplates?.[0]; + if (!proposedTemplate) throw new Error("Expected proposed template"); + proposedTemplate.name = "Assistant reviewed corporate template"; + const change = { + path: "projectTemplates", + operation: "merge" as const, + before: {}, + value: { [template.key]: { name: proposedTemplate.name } }, + }; + const conversation = { + id: contractRevision, + revision: 2, + baseRevision: 1, + expiresAt: new Date(Date.now() + 3600000).toISOString(), + stale: false, + messages: [], + proposal: { + explanation: + "Name des bestehenden Templates vorschlagen, sonstige Einstellungen erhalten.", + questions: [], + references: ["project-templates"], + changes: [change], + }, + }; + const reads: string[] = []; + let sent = 0; + let accepted = 0; + let proposalAvailable = false; + await page.route( + `**/api/v1/configurations/${configurationId}/assistant`, + (route) => { + const request = route.request(); + if (request.method() === "GET") { + reads.push(new URL(request.url()).pathname); + return route.fulfill({ + json: { conversation: proposalAvailable ? conversation : null }, + }); + } + expect(request.method()).toBe("POST"); + expect(request.headers()["x-lzc-csrf"]).toBe("csrf-workflow"); + expect(request.postDataJSON()).toEqual({ + revision: 1, + message: "Bitte den Namen dieses Templates verbessern.", + draft: original, + }); + sent++; + proposalAvailable = true; + return route.fulfill({ json: { conversation } }); + }, + ); + await page.route( + `**/api/v1/configurations/${configurationId}/assistant/accept`, + (route) => { + expect(route.request().method()).toBe("POST"); + expect(route.request().headers()["x-lzc-csrf"]).toBe("csrf-workflow"); + expect(route.request().postDataJSON()).toEqual({ + revision: 2, + baseRevision: 1, + selectedFields: assistantProposalFields([change]).map( + (field) => field.id, + ), + draft: original, + savedDraft: original, + }); + accepted++; + proposalAvailable = false; + return route.fulfill({ + json: { + configuration: { + id: configurationId, + revision: 1, + draft: candidate, + persisted: false, + }, + savedConfiguration: original, + conversation: { ...conversation, proposal: null }, + }, + }); + }, + ); + await page.goto("/configurations"); + await page + .getByRole("button", { + name: "Konfiguration öffnen: Existing platform workflow", + exact: true, + }) + .click(); + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Application Landing Zones", exact: true }) + .click(); + const drafts = page.getByRole("tabpanel", { + name: "Template-Entwürfe", + exact: true, + }); + const chat = drafts.getByRole("region", { + name: "Konfigurations-Chat", + exact: true, + }); + const templateName = drafts.getByLabel("Name des Templates", { exact: true }); + const next = drafts.getByRole("button", { + name: "Weiter: Plattformanbindung", + exact: true, + }); + await expect( + drafts.getByRole("heading", { + name: "Assistant für Template-Entwürfe", + exact: true, + }), + ).toBeVisible(); + await expect(chat).toBeVisible(); + await expect(templateName).toHaveValue(template.name); + await expect(next).toBeEnabled(); + await chat + .getByLabel("Deine Nachricht", { exact: true }) + .fill("Bitte den Namen dieses Templates verbessern."); + await chat + .getByRole("button", { name: "Nachricht senden", exact: true }) + .click(); + await expect(chat).toContainText(proposedTemplate.name); + expect(sent).toBe(1); + expect(accepted).toBe(0); + const readsBeforeNavigation = reads.length; + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Konfigurationen", exact: true }) + .click(); + const editorChat = page.getByRole("region", { + name: "Konfigurations-Chat", + exact: true, + }); + await expect(editorChat).toBeVisible(); + await expect(editorChat).toContainText(proposedTemplate.name); + await page + .getByRole("navigation", { name: "Hauptnavigation", exact: true }) + .getByRole("button", { name: "Application Landing Zones", exact: true }) + .click(); + await expect(chat).toBeVisible(); + await expect(chat).toContainText(proposedTemplate.name); + expect(reads.length).toBeGreaterThan(readsBeforeNavigation); + expect(sent).toBe(1); + expect(accepted).toBe(0); + await expect(templateName).toHaveValue(template.name); + await expect(next).toBeEnabled(); + expect(state.writes).toEqual([]); + await chat + .getByRole("button", { name: "Gesamten Vorschlag übernehmen", exact: true }) + .click(); + await expect(templateName).toHaveValue(proposedTemplate.name); + await expect(next).toBeDisabled(); + await expect(drafts).toContainText("Speichere zuerst die Konfiguration."); + expect(accepted).toBe(1); + expect(state.writes).toEqual([]); + expect(state.stored()).toEqual(original); + await drafts + .getByRole("button", { name: "Konfiguration speichern", exact: true }) + .click(); + await expect.poll(() => state.writes.length).toBe(1); + await expect(next).toBeEnabled(); + expect(state.stored()).toEqual(candidate); + expect(state.revision()).toBe(2); + expect(reads.length).toBeGreaterThan(0); + expect( + reads.every( + (path) => path === `/api/v1/configurations/${configurationId}/assistant`, + ), + ).toBe(true); + expect(state.executionWrites).toEqual([]); +}); diff --git a/landing-zone-configurator/docs/project-template-drafts.de.md b/landing-zone-configurator/docs/project-template-drafts.de.md index e752a97..6644b4b 100644 --- a/landing-zone-configurator/docs/project-template-drafts.de.md +++ b/landing-zone-configurator/docs/project-template-drafts.de.md @@ -98,6 +98,15 @@ noch fehlenden Application-Apply sind durch diesen Stand überholt. ## Fachlicher Ablauf +Der Reiter **Template-Entwürfe** unter **Application Landing Zones** zeigt den +bestehenden Konfigurations-Assistant direkt oberhalb des Template-Editors. Er nutzt +dieselbe gespeicherte Konfigurations-ID, dasselbe Gespräch und denselben Entwurf wie +der Konfigurationseditor. Nennen Sie das gewünschte Template und prüfen Sie die +vorgeschlagenen Felder vor der Übernahme. Die Übernahme ändert den lokalen Entwurf; +speichern Sie ausdrücklich vor Plattformanbindung oder Veröffentlichung. Sie +veröffentlicht keine Version und erteilt keine Ausführungsfreigabe. Application Owner +sehen diesen Konfigurations-Chat nicht und erhalten keinen Zugriff auf Plattformdaten. + Der Platform Engineer definiert zentrale Plattformdienste und die dafür nötigen Infrastrukturprojekte. Im Schritt **Projekt-Templates** erstellt er Vorlagen für spätere Anwendungsprojekte. Die Plattformkonfiguration erzeugt diese Projekte diff --git a/landing-zone-configurator/docs/project-template-drafts.md b/landing-zone-configurator/docs/project-template-drafts.md index d12ac42..78ef7c3 100644 --- a/landing-zone-configurator/docs/project-template-drafts.md +++ b/landing-zone-configurator/docs/project-template-drafts.md @@ -40,6 +40,14 @@ Apply never runs automatically. A potentially partially executed Apply is blocke
## Business workflow +The **Template drafts** tab under **Application Landing Zones** shows the existing +configuration Assistant directly above the template editor. It uses the same saved +configuration ID, conversation and root draft as the configuration editor. Name the +template you want to change and review the proposed fields before accepting them. +Acceptance changes the local draft; save explicitly before platform binding or +publication. It does not publish a version or authorize execution. Application Owners +do not see this configuration chat or gain access to platform configuration data. + The Platform Engineer defines central platform services and the infrastructure projects required for them. In the **Project templates** step, they create templates for future application projects. Platform configuration does not create those projects itself. The Application Owner later orders an instance of a published template version in separate Application self-service. New drafts from all eight Accelerator templates use this workflow. `landing_zones`, `sandboxes` and `landing_zone_namespace_services` from the examples become template drafts. Platform, connectivity, governance, central services and their projects remain part of platform configuration. The preview shows platform resources and template drafts separately.