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