From dcd44f19b62d985f1bf60705cb01c5c36904971a Mon Sep 17 00:00:00 2001 From: Omer Celik Date: Mon, 27 Jul 2026 01:16:28 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(console):=20the=20bulk=20intake=20pane?= =?UTF-8?q?l=20=E2=80=94=20upload,=20the=20real=20report,=20the=20four-eye?= =?UTF-8?q?s=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U3 slice 1. The panel is a client of the frozen bulk surface: definitions and their awaiting-approval age lead the screen, the batch list filters by STATE only (the contract has no definition filter, so the panel does not send one), the validation report walks by keyset on `afterRow`, and the gate offers approve/reject exactly where the engine allows a decision. Kit changes the gate needed: - `VerbButton` can collect an EVIDENCE note in its confirm step; a required note blocks the verb until the reason exists (the contract makes reject's note mandatory — it is what the next uploader reads). - `quiet` suppresses the button's own outcome strip for verbs whose control legitimately unmounts when the verb lands; the composite renders the outcome instead. Without it, approving a batch destroyed the very message it produced. - `Approved` joins the standard state map (info: the work is still ahead). The exit gate is real: the console smoke host now composes bulk, and Playwright uploads a file with a broken line, reads the report THE ENGINE wrote, is refused by the engine when invalid rows block approval, rejects with the mandatory reason, then uploads a clean file and approves it — 3/3 green locally. Co-Authored-By: Claude Opus 5 --- .github/workflows/nightly.yml | 8 +- CLAUDE.md | 9 +- docs/rfc/README.md | 2 +- docs/rfc/goldpath-console.md | 2 +- docs/strategy/ai-sdlc-status.md | 2 +- tests/Goldpath.Jobs.TestHost/Program.cs | 30 ++ ui/console/e2e/console.spec.ts | 93 +++++- ui/console/src/BulkPanel.test.tsx | 262 ++++++++++++++++ ui/console/src/BulkPanel.tsx | 364 ++++++++++++++++++++++ ui/console/src/Console.test.tsx | 10 +- ui/console/src/Console.tsx | 11 +- ui/console/src/adminClient.ts | 94 ++++++ ui/kit/src/components/VerbButton.test.tsx | 66 +++- ui/kit/src/components/VerbButton.tsx | 44 ++- ui/kit/src/status.ts | 1 + 15 files changed, 966 insertions(+), 32 deletions(-) create mode 100644 ui/console/src/BulkPanel.test.tsx create mode 100644 ui/console/src/BulkPanel.tsx diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 33a2777..d8e0641 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -125,9 +125,11 @@ jobs: - name: breaker acceptance vs CorPay run: bash evals/skills/breaker/accept.sh samples/corpay - # The U2 exit gate (console RFC D3): the REAL console driven against a REAL Goldpath - # app — capability discovery, the confirm gate, a run reaching terminal, and the repair - # queue replayed. Behaviour only; no screenshot diffing. + # The console exit gate (console RFC D3): the REAL console driven against a REAL + # Goldpath app — capability discovery, the confirm gate, a run reaching terminal, the + # repair queue replayed (U2), and the bulk intake journey end to end: upload → the + # engine's own validation report → refused approval → rejection with evidence → + # approval of a clean file (U3). Behaviour only; no screenshot diffing. console-smoke: runs-on: ubuntu-latest timeout-minutes: 25 diff --git a/CLAUDE.md b/CLAUDE.md index f04a7f0..326769b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,11 +22,12 @@ not propose anything that contradicts them; changes only via a superseding ADR). - "latest" dependencies are forbidden; everything is pinned. Air-gapped networks are a first-class scenario. - Code style: `.editorconfig` + analyzers + `dotnet format`; XML summaries mandatory on public APIs. -## Status (2026-07-24) -Phases 0–1 and the hardening set (H1–H8) are complete; the `0.1.0-preview.2` train is on -nuget.org (plus `specdrift` 0.4.1 as tool/MCP/Docker/Action). Phase D shipped the CorPay +## Status (2026-07-27) +Phases 0–1 and the hardening set (H1–H8) are complete; the `0.1.0-preview.3` train is on +nuget.org (plus `specdrift` 0.4.2 as tool/MCP/Docker/Action). Phase D shipped the CorPay reference app (`samples/corpay`, proven nightly against the published packages). The UI -phase is active (`docs/rfc/goldpath-console.md`, U1 in flight). Live status ledgers — +phase is active (`docs/rfc/goldpath-console.md`: U1–U2 met, U3 in flight — the bulk +intake panel is proven in the nightly console smoke). Live status ledgers — keep them updated in the same PR that changes reality: `docs/strategy/ai-sdlc-status.md` (AI-assisted SDLC vs reality) and `docs/strategy/coverage-matrix.md` (capability × sample). Roadmap gates: `docs/strategy/foundation.md` §12. diff --git a/docs/rfc/README.md b/docs/rfc/README.md index ee01791..c4b9029 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -38,6 +38,6 @@ All "implemented" modules are published on nuget.org at `0.1.0-preview.2`. | [goldpath-versioning](goldpath-versioning.md) | SemVer & support promise (H7) | accepted (binding) | | [goldpath-event-contracts](goldpath-event-contracts.md) | event contracts idiom (per-app `.Contracts`) | accepted (2026-07-14) | | [goldpath-skills-v1](goldpath-skills-v1.md) | the AI skill layer v1 | implemented — ships inside the template; field status: `../strategy/ai-sdlc-status.md` §2 | -| [goldpath-console](goldpath-console.md) | Goldpath.Console (the UI phase) | accepted — U1 in flight | +| [goldpath-console](goldpath-console.md) | Goldpath.Console (the UI phase) | accepted — U1–U2 met, U3 in flight | | [goldpath-ai](goldpath-ai.md) | Goldpath.Ai — opt-in runtime AI (gateway · tool registry · decision record · confidence gate) | **ACCEPTED** (2026-07-26, D1–D4 as recommended) — implementation opens after the UI phase | | [spec-engine-v1](spec-engine-v1.md) | specdrift (separate repo) | implemented — 0.4.2 published (NuGet tool + MCP + Docker + Action) | diff --git a/docs/rfc/goldpath-console.md b/docs/rfc/goldpath-console.md index f51f013..1a0f449 100644 --- a/docs/rfc/goldpath-console.md +++ b/docs/rfc/goldpath-console.md @@ -73,7 +73,7 @@ custom-develop ON, with the same kit, the same way they add features to the back |---|---|---| | U1 | `ui/kit`: tokens + primitives + composites | component tests green; kit gallery page | | U2 | Run console over the registry (single-app path) | **MET** (2026-07-26): `scripts/console-smoke.sh` drives the real console against a real Goldpath app (Postgres + Quartz + the frozen surface) — capability discovery, confirm gate, run reaching terminal, repair queue replayed; runs nightly | -| U3 | Module panels (bulk/campaign/notification/archival), capability-lit | each panel drives its module's real verbs in smoke | +| U3 | Module panels (bulk/campaign/notification/archival), capability-lit | each panel drives its module's real verbs in smoke — **bulk MET** (2026-07-27): the smoke host composes bulk, and Playwright uploads a real file, reads the report the engine wrote, is refused by the engine when invalid rows block approval, rejects with the mandatory reason, then approves a clean file; campaign/notification/archival remain open | | U4 | Cross-service registry + triage home + auth story + `MapGoldpathConsole()` + GmConsole in nightly | the full D3 bar; CorPay screenshots into the README | ## 6. Non-goals diff --git a/docs/strategy/ai-sdlc-status.md b/docs/strategy/ai-sdlc-status.md index 4b81b5a..3233672 100644 --- a/docs/strategy/ai-sdlc-status.md +++ b/docs/strategy/ai-sdlc-status.md @@ -30,7 +30,7 @@ document exists to prevent. | Skill quality (evals) | eval set per skill; a skill that fails evals is not released (§6.2) | **PARTIAL** — as of 2026-07-24 the 4 acceptance runners are portable (published specdrift tool, no local-checkout assumption; failures print their output) and the deterministic half runs nightly (`evals-acceptance`: runner syntax + breaker vs CorPay). The LLM half — running the skills themselves per fixture — stays deferred until an agent-in-CI story exists (P2) | nightly `evals-acceptance` job | agent-in-CI story (P2) unlocks full skill-run evals | | Model proficiency matrix | skill × model → pass-rate matrix (§6.2) | **NOT BUILT** — Phase 2 (needs the eval lane first) | — | after the eval lane | | Release / DevOps | release train + delivery telemetry (§6.5) | Train **SHIPPED-PROVEN** (OIDC trusted publishing, license gate, roll script); delivery report **NOT BUILT** — Phase 2 | release workflow + license gate | — | -| Operations | admin APIs + ops packs + console (§7.1) | Admin APIs + dashboards + runbooks **SHIPPED-PROVEN** per module; console **PARTIAL** (U1 in progress, RFC accepted) | admin contract frozen + integration proofs | console phases U1–U4 | +| Operations | admin APIs + ops packs + console (§7.1) | Admin APIs + dashboards + runbooks **SHIPPED-PROVEN** per module; console **PARTIAL** (U1–U2 met; U3's bulk intake panel proven in the nightly console smoke, three module panels open) | admin contract frozen + integration proofs | console phases U1–U4 | | Pipeline (chained skills + human gates) | §6.1 level 3 | **NOT BUILT** — Phase 2 by written deferral (§12) | — | — | | Domain memory | project knowledge that grows with every run (§6.3) | **NOT BUILT** — `domain-memory-v1.md` is a v0.1 draft | — | Phase 2/3 | diff --git a/tests/Goldpath.Jobs.TestHost/Program.cs b/tests/Goldpath.Jobs.TestHost/Program.cs index 65cf5b4..98c5f8c 100644 --- a/tests/Goldpath.Jobs.TestHost/Program.cs +++ b/tests/Goldpath.Jobs.TestHost/Program.cs @@ -84,12 +84,26 @@ static async Task RunConsoleHostAsync(string connectionString, string url) var consoleOrigin = Environment.GetEnvironmentVariable("GOLDPATH_CONSOLE_ORIGIN") ?? "http://localhost:5201"; web.Services.AddCors(cors => cors.AddDefaultPolicy(policy => policy .WithOrigins(consoleOrigin).AllowAnyHeader().AllowAnyMethod().AllowCredentials())); + // Bulk is composed too, so the console's capability probe LIGHTS the intake panel and + // the U3 gate can drive the real four-eyes verbs (upload → validate → approve/reject). + web.Services.AddScoped, SmokePaymentHandler>(); + web.AddGoldpathBulk(bulk => + { + bulk.ChunkSize = 5; + bulk.AddBatch("payments", b => b + .MaxRows(10_000) + .RowKey(r => r.EndToEndId)); + }); + web.AddGoldpathJobs(jobs => { jobs.SchedulerName = "console-smoke"; jobs.ConnectionName = "jobsdb"; jobs.CheckinInterval = TimeSpan.FromSeconds(1); jobs.AddJob(); + // Validation runs on a tight cron so the smoke can WAIT for a real report instead + // of forging one; execution stays far-future — the gate decides when rows move. + jobs.AddGoldpathBulkJobs(validateCron: "0/5 * * * * ?", executeCron: "0 0 0 1 1 ? 2099"); }); var app = web.Build(); @@ -104,6 +118,7 @@ static async Task RunConsoleHostAsync(string connectionString, string url) app.Urls.Add(url); app.UseCors(); app.MapGoldpathJobsAdmin(exposeUnsecured: true); + app.MapGoldpathBulkAdmin(exposeUnsecured: true); await app.StartAsync(); Console.WriteLine("CONSOLEHOST-READY"); await app.WaitForShutdownAsync(); @@ -205,6 +220,21 @@ public async Task ExecuteChunkAsync(GoldpathJobChunk chunk, GoldpathJobContext c } } + /// The console smoke's row handler: records the payment, no artificial delay. + public sealed class SmokePaymentHandler(ClusterDb db) : IGoldpathBulkRowHandler + { + public async Task ExecuteAsync(ClusterPaymentRow row, GoldpathBulkRowContext context, CancellationToken cancellationToken) + { + db.PaymentSink.Add(new PaymentSinkEntry + { + EndToEndId = row.EndToEndId, + RowNumber = context.RowNumber, + Instance = Environment.ProcessId.ToString(), + }); + await db.SaveChangesAsync(cancellationToken); + } + } + /// Chaining proof: must run exactly once, AFTER the slow job completes. public sealed class ChainedProofJob : IGoldpathJob { diff --git a/ui/console/e2e/console.spec.ts b/ui/console/e2e/console.spec.ts index cadc8b5..5f36f29 100644 --- a/ui/console/e2e/console.spec.ts +++ b/ui/console/e2e/console.spec.ts @@ -11,9 +11,10 @@ test.describe("the run console against a real Goldpath app", () => { await page.goto(`/?base=${encodeURIComponent(service)}`); await expect(page.getByRole("button", { name: "Runs" })).toBeVisible(); - // This host composes jobs ONLY — the other four surfaces answer 404, so no panels. - await expect(page.getByRole("button", { name: "Bulk intake" })).toHaveCount(0); + // This host composes jobs + bulk; the other three surfaces answer 404, so no panels. + await expect(page.getByRole("button", { name: "Bulk intake" })).toBeVisible(); await expect(page.getByRole("button", { name: "Campaigns" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Archival" })).toHaveCount(0); }); test("triggers a job, watches the run finish, and replays its repair item", async ({ page }) => { @@ -22,20 +23,23 @@ test.describe("the run console against a real Goldpath app", () => { // The fleet appears because the executor exists (zero-config discovery). await expect(page.getByRole("button", { name: /console-smoke/ })).toBeVisible(); - // Trigger through the confirm gate — the console never fires a verb on one click. - await page.getByRole("button", { name: "trigger" }).click(); + // Scope the verb to ITS job row: this fleet also carries the bulk validate/execute + // jobs, so an unscoped "trigger" would be three buttons and one ambiguous click. + const smokeJob = page.locator("li", { hasText: "SmokeJob" }); + await smokeJob.getByRole("button", { name: "trigger" }).click(); const dialog = page.getByRole("alertdialog"); await expect(dialog).toContainText("audited"); await dialog.getByRole("button", { name: "trigger" }).click(); await expect(page.getByRole("status")).toBeVisible(); // The run appears and reaches a terminal state — polled through the real API. + const smokeRun = page.getByRole("row", { name: /SmokeJob/ }).first(); await expect(async () => { await page.goto(`/?base=${encodeURIComponent(service)}`); - await expect(page.getByText("Completed").first()).toBeVisible({ timeout: 5_000 }); + await expect(smokeRun).toContainText("Completed", { timeout: 5_000 }); }).toPass({ timeout: 60_000 }); - await page.locator("table button").first().click(); + await smokeRun.getByRole("button").first().click(); const detail = page.getByTestId("run-detail"); await expect(detail).toBeVisible(); await expect(detail).toContainText("Completed:"); // chunk counts by status @@ -49,4 +53,81 @@ test.describe("the run console against a real Goldpath app", () => { await replayDialog.getByRole("button", { name: "replay-items" }).click(); await expect(page.getByRole("status")).toBeVisible(); }); + + test("uploads a batch, reads the real validation report, and works the four-eyes gate", async ({ page }) => { + const openBulk = async () => { + await page.goto(`/?base=${encodeURIComponent(service)}`); + await page.getByRole("button", { name: "Bulk intake" }).click(); + await expect(page.getByTestId("bulk-panel")).toBeVisible(); + }; + + const upload = async (name: string, csv: string) => { + await page.getByLabel("batch file").setInputFiles({ name, mimeType: "text/csv", buffer: Buffer.from(csv) }); + await page.getByRole("button", { name: "upload" }).click(); + const dialog = page.getByRole("alertdialog", { name: "confirm upload" }); + await expect(dialog).toContainText(`Upload ${name} into payments?`); + await dialog.getByRole("button", { name: "upload" }).click(); + await expect(page.getByText(/Received — batch .* is queued for validation/)).toBeVisible(); + }; + + // The newest batch is the first row (the contract orders by receipt, newest first). + const newest = page.getByRole("row", { name: /payments/ }).first(); + const waitForState = async (state: string) => + expect(async () => { + await openBulk(); + // Read the state from the batch ROW: the filter's