From 803bffb6f7acee9f2b75d1144aa71e60ccca58d8 Mon Sep 17 00:00:00 2001 From: random block Date: Tue, 18 Aug 2026 15:03:34 +0100 Subject: [PATCH 1/3] Add recovery-only gateway demo drain --- .../dacs-directory/README.md | 1 + .../e2e/try-dacs-recovery.spec.ts | 37 +++++++++++++++++++ .../dacs-directory/package.json | 2 +- .../playwright.recovery.config.ts | 29 +++++++++++++++ .../dacs-directory/src/components/TryDacs.tsx | 29 +++++++++++---- .../src/components/gateway-demo-recovery.ts | 7 ++++ .../test/gateway-demo-recovery.test.ts | 14 +++++++ 7 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts create mode 100644 reference-implementations/dacs-directory/playwright.recovery.config.ts create mode 100644 reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts create mode 100644 reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 48b8e3a..88f9254 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -125,6 +125,7 @@ vendor directory. | `DACS_TRUST_PROXY` | No | Set to `1` only behind a trusted proxy that overwrites client-IP headers; otherwise the in-process rate limiter is disabled and the deployment must enforce its edge limit | | `NEXT_PUBLIC_DIRECTORY_URL` | Production | Public origin used by canonical URLs, sitemap, `llms.txt`, and machine-discovery documents; defaults to `http://localhost:3400`, which silently poisons production canonical URLs and the sitemap — the server logs a warning when unset in production | | `NEXT_PUBLIC_BUTLER_ORIGIN` | Production | Public HTTPS origin of the DACS agent gateway used by `/try`; defaults to `http://127.0.0.1:8402` only for local development. Railway validates this at build time. | +| `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY` | Temporary drain only | Set to exact `1` only after the gateway-owned demo is live. The old `/try` page blocks every fresh run but preserves origin-scoped `Check & resume` recovery. Remove the flag by deploying the gateway-demo removal after the drain window. | The data directory must be persistent and writable in deployments that accept registrations or run the indexer. Never commit `.indexer-seed`, `.indexer-mnemonic`, diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts new file mode 100644 index 0000000..89bb7bd --- /dev/null +++ b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { + PROCUREMENT_RUN_KEY, + completedJob, + expectAcceptedEvidence, + installMockGateway, +} from "./try-dacs-fixtures.js"; + +test("recovery-only deployment blocks fresh starts but preserves an existing job", async ({ context, page }) => { + let posts = 0; + await installMockGateway(context, { + onProcurementPost: async (route) => { + posts += 1; + await route.abort("blockedbyclient"); + }, + }); + await page.addInitScript(({ key, value }) => { + window.localStorage.setItem(key, JSON.stringify(value)); + }, { + key: PROCUREMENT_RUN_KEY, + value: { + runId: "recovery-only-existing-run", + goal: "Resume the existing security audit", + input: { profileId: "security-audit-rfq", paymentRail: "pay-dem", files: [{ path: "server.js", content: "safe" }] }, + startedAt: "2026-08-18T12:00:00.000Z", + jobId: completedJob.id, + }, + }); + + await page.goto("/try"); + await expect(page.getByTestId("gateway-demo-recovery-only")).toContainText("New purchases are paused"); + await expect(page.getByRole("button", { name: /Security Auditor/ }).first()).toBeDisabled(); + + await page.getByRole("button", { name: /Check & resume/ }).click(); + await expectAcceptedEvidence(page); + expect(posts).toBe(0); +}); diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index f3937b4..fedde86 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -13,7 +13,7 @@ "check:deploy-config": "node scripts/check-butler-origin.mjs", "check:butler": "node scripts/check-butler-origin.mjs --probe", "test": "tsx --test test/*.test.ts test/*.test.mjs", - "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts", + "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts e2e/try-dacs.spec.ts e2e/try-chat.spec.ts && playwright test --config playwright.recovery.config.ts e2e/try-dacs-recovery.spec.ts", "test:e2e:live": "playwright test e2e/try-dacs.live.spec.ts", "test:e2e:ui": "playwright test --ui", "test:seed": "tsx --test test/seed-smoke.test.ts", diff --git a/reference-implementations/dacs-directory/playwright.recovery.config.ts b/reference-implementations/dacs-directory/playwright.recovery.config.ts new file mode 100644 index 0000000..791b2f1 --- /dev/null +++ b/reference-implementations/dacs-directory/playwright.recovery.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + outputDir: "test-results/playwright-recovery", + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [["list"]], + use: { + baseURL: "http://localhost:3401", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "npx next dev -p 3401", + url: "http://localhost:3401/try", + reuseExistingServer: false, + timeout: 120_000, + env: { + NEXT_PUBLIC_DIRECTORY_URL: "http://localhost:3401", + NEXT_PUBLIC_BUTLER_ORIGIN: "https://butler.agentcommerce.network", + NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY: "1", + }, + }, +}); diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 5fcb84d..03f84b9 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -36,9 +36,11 @@ import { type FieldErrors, } from "./try-dacs-forms.js"; import AgentInputForm from "./try-forms/AgentInputForm.js"; +import { GATEWAY_DEMO_RECOVERY_MESSAGE, gatewayDemoRecoveryOnly } from "./gateway-demo-recovery.js"; import { ProcurementLockUnavailableError, parseStoredProcurementRun, resumeDispatchDecision, stageEvents, withExclusiveProcurementLock, type LockRequestor, type StoredProcurementRun } from "./try-dacs-stages.js"; const BUTLER = (process.env.NEXT_PUBLIC_BUTLER_ORIGIN ?? "http://127.0.0.1:8402").replace(/\/$/, ""); +const GATEWAY_DEMO_RECOVERY_ONLY = gatewayDemoRecoveryOnly(); const PROCUREMENT_RUN_KEY = "dacs-try:procurement-run"; const PROFILE_AGENT: Record = { "oracle-auto-accept": { name: "oracle-desk", label: "Oracle Desk" }, @@ -672,6 +674,10 @@ export default function TryDacs() { } async function runAgent() { + if (GATEWAY_DEMO_RECOVERY_ONLY) { + setError(GATEWAY_DEMO_RECOVERY_MESSAGE); + return; + } if (!plan) return; let parsed: Record; try { parsed = parseAgentInput(inputValue); } @@ -1058,6 +1064,15 @@ export default function TryDacs() {

Choose a real procurement route and how to pay: native DEM on Demos, or USDC through x402 on Base Sepolia. The Butler verifies the complete deal and exposes every receipt as it happens.

+ {GATEWAY_DEMO_RECOVERY_ONLY && ( +
+
+ New purchases are paused during the demo move +

{GATEWAY_DEMO_RECOVERY_MESSAGE}

+
+
+ )} + {/* Suppress the banner only when THIS tab is already tracking the record's job. A record with no jobId (the reload-raced-the-POST case) must always surface — that is the exact state it protects. */} @@ -1091,7 +1106,7 @@ export default function TryDacs() {
{agents.map((agent) => { const profile = profiles.find((candidate) => procurementProfileCard(candidate).name === agent.name); const liveRails = profile?.paymentRails.filter((rail) => profile.railReadiness[rail]?.executable).map(paymentRailLabel).join(" · "); - return ; + return ; })}
) : phase === "error" && plan ? ( @@ -1099,7 +1114,7 @@ export default function TryDacs() { {isProcurementSel && procurementJob?.status === "failed" && procurementJob.failedBeforePayment === true ? ( <>
Procurement failed before any paymentThe gateway confirms no money moved (its reason is shown above). Retrying starts a fresh purchase attempt with a new idempotency key.
-
+
) : isProcurementSel && procurementJob?.status === "failed" ? ( <> @@ -1114,18 +1129,18 @@ export default function TryDacs() { ) : isProcurementSel ? ( <>
Procurement stopped safelyRetrying reuses this run’s idempotency key, so the gateway resumes the existing job rather than starting a second paid purchase.
-
+
) : ( <>
{plan.butler.label} stopped safelyYour entered job details are still available.
-
+
)} ) : plan && phase === "ready" && selected ? (
-
{selectedProfile ? procurementModeLabel(selectedProfile.mode) : "Job details"}{plan.inputNote}
{inputIsValid ? `ready · ${paymentRailLabel(selectedPaymentRail)}` : "fields need attention"}
+
{selectedProfile ? procurementModeLabel(selectedProfile.mode) : "Job details"}{plan.inputNote}
{GATEWAY_DEMO_RECOVERY_ONLY ? "recovery only" : inputIsValid ? `ready · ${paymentRailLabel(selectedPaymentRail)}` : "fields need attention"}
{selectedProfile &&
Choose payment railThis changes the real asset and settlement network.
{selectedProfile.paymentRails.map((rail) => { @@ -1148,7 +1163,7 @@ export default function TryDacs() {
- +
) : phase === "running" && isProcurementSel ? ( @@ -1222,7 +1237,7 @@ export default function TryDacs() {
THREE WAYS TO PROCURE

Production agents, DEM or x402, full DACS

Each route uses the gateway’s rail-specific live schema and runs Identify → Vet → Negotiate → Settle → Verify. Sealed tender stays hidden until its DACS-3 role model is released.

{profiles.map((profile, index) => { const agent = procurementProfileCard(profile, defaultPaymentRail(profile)); - return ; + return ; })}
); diff --git a/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts new file mode 100644 index 0000000..96388d3 --- /dev/null +++ b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts @@ -0,0 +1,7 @@ +export const GATEWAY_DEMO_RECOVERY_MESSAGE = + "New purchases are paused while the buyer demo moves to its gateway-owned origin. Existing runs can still be checked and resumed with their original idempotency key."; + +/** Build-time drain control for the retiring Community-hosted buyer demo. */ +export function gatewayDemoRecoveryOnly(value = process.env.NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY): boolean { + return value === "1"; +} diff --git a/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts b/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts new file mode 100644 index 0000000..92a1cc2 --- /dev/null +++ b/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + GATEWAY_DEMO_RECOVERY_MESSAGE, + gatewayDemoRecoveryOnly, +} from "../src/components/gateway-demo-recovery.js"; + +test("gateway demo drain is opt-in and exact", () => { + assert.equal(gatewayDemoRecoveryOnly(undefined), false); + assert.equal(gatewayDemoRecoveryOnly("0"), false); + assert.equal(gatewayDemoRecoveryOnly("true"), false); + assert.equal(gatewayDemoRecoveryOnly("1"), true); + assert.match(GATEWAY_DEMO_RECOVERY_MESSAGE, /Existing runs can still be checked and resumed/); +}); From 39a4d72a858e0f213f47ffce19ba5d79c2f04457 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:44:18 +0000 Subject: [PATCH 2/3] Never re-send a run without a job id during the gateway demo drain In recovery-only mode, Check & resume on a stored record with no jobId re-POSTed /demo/procurement under the stored key. If the original POST never reached the gateway, that created and paid for a new job after the drain had paused new purchases (review round 1, P1/P2). resumeDispatchDecision now takes { recoveryOnly } and returns manual-reconcile instead of post for such a record. A job id learned by another tab is still read. TryDacs keeps the record and shows its run id for the operator. Also from review: the default Playwright config ignores the recovery spec, the recovery spec checks every fresh-start button plus the no-jobId and raced-jobId cases, and the README and e2e/README note that the flag is build-time and describe the second e2e pass. Co-Authored-By: Claude Opus 5.5 --- .../dacs-directory/README.md | 2 +- .../dacs-directory/e2e/README.md | 2 +- .../e2e/try-dacs-recovery.spec.ts | 92 ++++++++++++++++--- .../dacs-directory/playwright.config.ts | 3 + .../playwright.recovery.config.ts | 1 + .../dacs-directory/src/components/TryDacs.tsx | 23 +++-- .../src/components/gateway-demo-recovery.ts | 11 ++- .../src/components/try-dacs-stages.ts | 11 ++- .../test/gateway-demo-recovery.test.ts | 10 +- .../test/try-dacs-stages.test.ts | 13 +++ 10 files changed, 141 insertions(+), 27 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 88f9254..18dac08 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -125,7 +125,7 @@ vendor directory. | `DACS_TRUST_PROXY` | No | Set to `1` only behind a trusted proxy that overwrites client-IP headers; otherwise the in-process rate limiter is disabled and the deployment must enforce its edge limit | | `NEXT_PUBLIC_DIRECTORY_URL` | Production | Public origin used by canonical URLs, sitemap, `llms.txt`, and machine-discovery documents; defaults to `http://localhost:3400`, which silently poisons production canonical URLs and the sitemap — the server logs a warning when unset in production | | `NEXT_PUBLIC_BUTLER_ORIGIN` | Production | Public HTTPS origin of the DACS agent gateway used by `/try`; defaults to `http://127.0.0.1:8402` only for local development. Railway validates this at build time. | -| `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY` | Temporary drain only | Set to exact `1` only after the gateway-owned demo is live. The old `/try` page blocks every fresh run but preserves origin-scoped `Check & resume` recovery. Remove the flag by deploying the gateway-demo removal after the drain window. | +| `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY` | Temporary drain only | Build-time (inlined by `next build`; rebuild and redeploy to change it). Set to exact `1` only after the gateway-owned demo is live. The old `/try` page then sends no procurement POST at all: it blocks every fresh run, and `Check & resume` only reads runs whose gateway job id the browser recorded. A run without a job id is never re-sent; the page shows its run id for manual reconciliation. Remove the flag by deploying the gateway-demo removal after the drain window. | The data directory must be persistent and writable in deployments that accept registrations or run the indexer. Never commit `.indexer-seed`, `.indexer-mnemonic`, diff --git a/reference-implementations/dacs-directory/e2e/README.md b/reference-implementations/dacs-directory/e2e/README.md index e0dca01..b471c04 100644 --- a/reference-implementations/dacs-directory/e2e/README.md +++ b/reference-implementations/dacs-directory/e2e/README.md @@ -2,7 +2,7 @@ The Playwright suite has two layers: -- `npm run test:e2e` runs seven deterministic browser regressions: six mocked `/try` payment-safety scenarios plus a `/try-chat` replay check that proves the explainer cannot dispatch a purchase. It never spends DEM or USDC and is safe for CI. +- `npm run test:e2e` runs two deterministic passes. The first (`playwright.config.ts`, port 3400) runs the landing-page, seller-registration, mocked `/try` payment-safety and `/try-chat` replay regressions. The second (`playwright.recovery.config.ts`, port 3401) builds `/try` with `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1` and runs `try-dacs-recovery.spec.ts`, which proves the drain sends no procurement POST: fresh starts are disabled, a run with a job id is read, and a run without one goes to manual reconciliation. The default config ignores that spec because it needs the flag. Neither pass spends DEM or USDC; both are safe for CI. - `npm run test:e2e:live` contains five serial checks against the live Butler gateway. It is skipped unless the operator explicitly authorizes a capped testnet purchase. ## Install the browser diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts index 89bb7bd..1da3e5c 100644 --- a/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts +++ b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts @@ -1,37 +1,99 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type BrowserContext, type Page } from "@playwright/test"; import { PROCUREMENT_RUN_KEY, completedJob, expectAcceptedEvidence, installMockGateway, + procurementOptions, } from "./try-dacs-fixtures.js"; -test("recovery-only deployment blocks fresh starts but preserves an existing job", async ({ context, page }) => { - let posts = 0; +const storedInput = { profileId: "security-audit-rfq", paymentRail: "pay-dem", files: [{ path: "server.js", content: "safe" }] }; + +async function countProcurementPosts(context: BrowserContext) { + const posts = { count: 0 }; await installMockGateway(context, { onProcurementPost: async (route) => { - posts += 1; + posts.count += 1; await route.abort("blockedbyclient"); }, }); + return posts; +} + +async function seedStoredRun(page: Page, value: Record) { await page.addInitScript(({ key, value }) => { window.localStorage.setItem(key, JSON.stringify(value)); - }, { - key: PROCUREMENT_RUN_KEY, - value: { - runId: "recovery-only-existing-run", - goal: "Resume the existing security audit", - input: { profileId: "security-audit-rfq", paymentRail: "pay-dem", files: [{ path: "server.js", content: "safe" }] }, - startedAt: "2026-08-18T12:00:00.000Z", - jobId: completedJob.id, - }, + }, { key: PROCUREMENT_RUN_KEY, value }); +} + +test("recovery-only deployment blocks fresh starts but preserves an existing job", async ({ context, page }) => { + const posts = await countProcurementPosts(context); + await seedStoredRun(page, { + runId: "recovery-only-existing-run", + goal: "Resume the existing security audit", + input: storedInput, + startedAt: "2026-08-18T12:00:00.000Z", + jobId: completedJob.id, }); await page.goto("/try"); await expect(page.getByTestId("gateway-demo-recovery-only")).toContainText("New purchases are paused"); - await expect(page.getByRole("button", { name: /Security Auditor/ }).first()).toBeDisabled(); + // Every fresh-start entry point is disabled: the picker grid and the lower + // "THREE WAYS TO PROCURE" grid, not just the first matching button. + await expect(page.locator(".try-agent-grid button")).toHaveCount(procurementOptions.profiles.length); + for (const button of await page.locator(".picker-grid button, .try-agent-grid button").all()) { + await expect(button).toBeDisabled(); + } + const auditorButtons = await page.getByRole("button", { name: /Security Auditor/ }).all(); + expect(auditorButtons.length).toBeGreaterThanOrEqual(2); + for (const button of auditorButtons) await expect(button).toBeDisabled(); await page.getByRole("button", { name: /Check & resume/ }).click(); await expectAcceptedEvidence(page); - expect(posts).toBe(0); + expect(posts.count).toBe(0); +}); + +test("recovery-only deployment never re-sends a run whose job id was never received", async ({ context, page }) => { + const posts = await countProcurementPosts(context); + await seedStoredRun(page, { + runId: "recovery-only-unsent-run", + goal: "A run whose original POST may never have reached the gateway", + input: storedInput, + startedAt: new Date().toISOString(), + }); + + await page.goto("/try"); + await expect(page.locator(".resume-banner[role=alert]")).toContainText("give the operator run id recovery-only-unsent-run"); + await page.getByRole("button", { name: /Check & resume/ }).click(); + + await expect(page.locator(".bubble.error")).toContainText("run id recovery-only-unsent-run"); + await expect(page.locator(".bubble.error")).toContainText("nothing was sent"); + expect(posts.count).toBe(0); + // The record stays so the banner keeps the run id until the user dismisses it. + const stored = await page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY); + expect(JSON.parse(stored ?? "null")).toMatchObject({ runId: "recovery-only-unsent-run" }); + await expect(page.locator(".resume-banner[role=alert]")).toBeVisible(); +}); + +test("recovery-only resume follows a job id another tab learned instead of re-sending", async ({ context, page }) => { + const posts = await countProcurementPosts(context); + const record = { + runId: "recovery-only-raced-run", + goal: "Resume the security audit another tab reconnected", + input: storedInput, + startedAt: new Date().toISOString(), + }; + await seedStoredRun(page, record); + + await page.goto("/try"); + await expect(page.locator(".resume-banner[role=alert]")).toContainText("the job id was never received"); + // Another tab of the same run received the job id after this tab loaded. + await page.evaluate(({ key, value }) => localStorage.setItem(key, JSON.stringify(value)), { + key: PROCUREMENT_RUN_KEY, + value: { ...record, jobId: completedJob.id }, + }); + await page.getByRole("button", { name: /Check & resume/ }).click(); + + await expectAcceptedEvidence(page); + expect(posts.count).toBe(0); }); diff --git a/reference-implementations/dacs-directory/playwright.config.ts b/reference-implementations/dacs-directory/playwright.config.ts index e25f23e..c8b839f 100644 --- a/reference-implementations/dacs-directory/playwright.config.ts +++ b/reference-implementations/dacs-directory/playwright.config.ts @@ -5,6 +5,9 @@ const livePaidRun = process.env.RUN_LIVE_PAID_E2E === "1"; export default defineConfig({ testDir: "./e2e", + // Needs NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1 at build time; it runs + // only under playwright.recovery.config.ts on its own server. + testIgnore: /try-dacs-recovery\.spec\.ts$/, outputDir: livePaidRun ? "test-results/playwright-live" : "test-results/playwright", fullyParallel: false, forbidOnly: Boolean(process.env.CI), diff --git a/reference-implementations/dacs-directory/playwright.recovery.config.ts b/reference-implementations/dacs-directory/playwright.recovery.config.ts index 791b2f1..9c8187a 100644 --- a/reference-implementations/dacs-directory/playwright.recovery.config.ts +++ b/reference-implementations/dacs-directory/playwright.recovery.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", + testMatch: /try-dacs-recovery\.spec\.ts$/, outputDir: "test-results/playwright-recovery", fullyParallel: false, forbidOnly: Boolean(process.env.CI), diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 03f84b9..4292229 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -36,7 +36,7 @@ import { type FieldErrors, } from "./try-dacs-forms.js"; import AgentInputForm from "./try-forms/AgentInputForm.js"; -import { GATEWAY_DEMO_RECOVERY_MESSAGE, gatewayDemoRecoveryOnly } from "./gateway-demo-recovery.js"; +import { GATEWAY_DEMO_RECOVERY_MESSAGE, gatewayDemoManualReconcileMessage, gatewayDemoRecoveryOnly } from "./gateway-demo-recovery.js"; import { ProcurementLockUnavailableError, parseStoredProcurementRun, resumeDispatchDecision, stageEvents, withExclusiveProcurementLock, type LockRequestor, type StoredProcurementRun } from "./try-dacs-stages.js"; const BUTLER = (process.env.NEXT_PUBLIC_BUTLER_ORIGIN ?? "http://127.0.0.1:8402").replace(/\/$/, ""); @@ -60,6 +60,14 @@ class StaleResumeRecordError extends Error { } } +/** Thrown when a recovery-only resume would have to re-POST a record with no job id. */ +class ManualReconcileRequiredError extends Error { + constructor(runId: string) { + super(gatewayDemoManualReconcileMessage(runId)); + this.name = "ManualReconcileRequiredError"; + } +} + /** The browser's Web Locks manager, or undefined where unsupported. */ function browserLocks(): LockRequestor | undefined { return typeof navigator !== "undefined" && navigator.locks @@ -946,8 +954,9 @@ export default function TryDacs() { // section re-validates its captured record against storage NOW and // must never act on (or overwrite) a record it no longer owns. job = await withExclusiveProcurementLock(browserLocks(), controller.signal, async () => { - const decision = resumeDispatchDecision(stored, readStoredRun()); + const decision = resumeDispatchDecision(stored, readStoredRun(), { recoveryOnly: GATEWAY_DEMO_RECOVERY_ONLY }); if (decision.action === "abort-stale") throw new StaleResumeRecordError(); + if (decision.action === "manual-reconcile") throw new ManualReconcileRequiredError(decision.runId); if (decision.action === "read") { // Another tab already learned this run's job id — read it, never re-POST. const { response, body } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement/${encodeURIComponent(decision.jobId)}`, { signal: controller.signal }, deadline); @@ -972,9 +981,11 @@ export default function TryDacs() { setProcurementJob(job); await followProcurementJob(job, controller, deadline); } catch (cause) { - if (cause instanceof StaleResumeRecordError) { - // The record now belongs to another run (or is gone); this tab must - // not keep its key or offer procurement retries against it. + if (cause instanceof StaleResumeRecordError || cause instanceof ManualReconcileRequiredError) { + // The record now belongs to another run (or is gone), or the drain + // forbids re-sending it; this tab must not keep its key or offer + // procurement retries against it. A manual-reconcile record stays + // stored so the banner keeps showing its run id until dismissed. procurementRunId.current = null; setPlan(null); setPhase("idle"); setError(cause.message); @@ -1080,7 +1091,7 @@ export default function TryDacs() {
A procurement run from this browser is still on record -

Started {new Date(storedRun.startedAt).toLocaleString()}{storedRun.jobId ? ` · job ${compact(storedRun.jobId, 8, 6)}` : " · the job id was never received"}. Resuming reconnects under the same idempotency key, so it can never start a second purchase.

+

Started {new Date(storedRun.startedAt).toLocaleString()}{storedRun.jobId ? ` · job ${compact(storedRun.jobId, 8, 6)}` : " · the job id was never received"}. {GATEWAY_DEMO_RECOVERY_ONLY && !storedRun.jobId ? `New purchases are paused, so this run is never re-sent: Check & resume only follows a job id another tab received. Otherwise give the operator run id ${storedRun.runId}.` : "Resuming reconnects under the same idempotency key, so it can never start a second purchase."}

diff --git a/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts index 96388d3..8a59657 100644 --- a/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts +++ b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts @@ -1,5 +1,14 @@ export const GATEWAY_DEMO_RECOVERY_MESSAGE = - "New purchases are paused while the buyer demo moves to its gateway-owned origin. Existing runs can still be checked and resumed with their original idempotency key."; + "New purchases are paused while the buyer demo moves to its gateway-owned origin. Existing runs whose gateway job id this browser received can still be checked and resumed; a run without one is never re-sent and goes to manual reconciliation."; + +/** + * Shown when a drain-mode resume finds a record without a job id: the + * original POST may never have reached the gateway, so re-sending it could + * start a new paid purchase that the drain's reconciliation never sees. + */ +export function gatewayDemoManualReconcileMessage(runId: string): string { + return `This run's gateway job id was never received, and new purchases are paused during the demo move, so nothing was sent: re-sending it could start a new paid purchase. Give the operator run id ${runId} to reconcile it, then dismiss this reminder.`; +} /** Build-time drain control for the retiring Community-hosted buyer demo. */ export function gatewayDemoRecoveryOnly(value = process.env.NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY): boolean { diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts b/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts index 5808e69..1ff9fa0 100644 --- a/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts +++ b/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts @@ -142,18 +142,25 @@ export function withExclusiveProcurementLock( * on the stale capture could overwrite the live run's recovery handle. * - same run, jobId now known (another tab already re-POSTed) → READ that * job; a second POST is unnecessary. - * - same run, still no jobId → the idempotent re-POST proceeds. + * - same run, still no jobId → the idempotent re-POST proceeds, EXCEPT in + * the recovery-only drain: the original POST may never have reached the + * gateway, so the re-POST could be the first request for its key and start + * (and pay for) a new job. Without a read-only lookup by key, the run is + * handed to manual reconciliation instead. */ export type ResumeDispatchDecision = | { action: "abort-stale" } | { action: "read"; jobId: string } - | { action: "post" }; + | { action: "post" } + | { action: "manual-reconcile"; runId: string }; export function resumeDispatchDecision( captured: StoredProcurementRun, current: StoredProcurementRun | null, + { recoveryOnly = false }: { recoveryOnly?: boolean } = {}, ): ResumeDispatchDecision { if (!current || current.runId !== captured.runId) return { action: "abort-stale" }; if (current.jobId) return { action: "read", jobId: current.jobId }; + if (recoveryOnly) return { action: "manual-reconcile", runId: current.runId }; return { action: "post" }; } diff --git a/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts b/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts index 92a1cc2..875f846 100644 --- a/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts +++ b/reference-implementations/dacs-directory/test/gateway-demo-recovery.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { GATEWAY_DEMO_RECOVERY_MESSAGE, + gatewayDemoManualReconcileMessage, gatewayDemoRecoveryOnly, } from "../src/components/gateway-demo-recovery.js"; @@ -10,5 +11,12 @@ test("gateway demo drain is opt-in and exact", () => { assert.equal(gatewayDemoRecoveryOnly("0"), false); assert.equal(gatewayDemoRecoveryOnly("true"), false); assert.equal(gatewayDemoRecoveryOnly("1"), true); - assert.match(GATEWAY_DEMO_RECOVERY_MESSAGE, /Existing runs can still be checked and resumed/); + assert.match(GATEWAY_DEMO_RECOVERY_MESSAGE, /can still be checked and resumed/); + assert.match(GATEWAY_DEMO_RECOVERY_MESSAGE, /never re-sent/); +}); + +test("a drain-mode run without a job id is handed to the operator by run id", () => { + const text = gatewayDemoManualReconcileMessage("run-without-job"); + assert.match(text, /run id run-without-job/); + assert.match(text, /nothing was sent/); }); diff --git a/reference-implementations/dacs-directory/test/try-dacs-stages.test.ts b/reference-implementations/dacs-directory/test/try-dacs-stages.test.ts index f71e37e..63ba6df 100644 --- a/reference-implementations/dacs-directory/test/try-dacs-stages.test.ts +++ b/reference-implementations/dacs-directory/test/try-dacs-stages.test.ts @@ -194,6 +194,19 @@ test("resume decisions: stale records abort, known job ids read, only the raced assert.deepEqual(resumeDispatchDecision(captured, { ...captured }), { action: "post" }); }); +test("recovery-only resume decisions never post: a record without a job id goes to manual reconciliation", () => { + const captured: StoredProcurementRun = { runId: "run-x", goal: "g", input: { budgetDem: 5 }, startedAt: at }; + const drain = { recoveryOnly: true }; + // The original POST may never have reached the gateway, so re-POSTing the + // stored key during the drain could create (and pay for) a brand-new job. + assert.deepEqual(resumeDispatchDecision(captured, { ...captured }, drain), { action: "manual-reconcile", runId: "run-x" }); + // A job id learned meanwhile (e.g. by another tab) is still read-only recovery. + assert.deepEqual(resumeDispatchDecision(captured, { ...captured, jobId: "job-1" }, drain), { action: "read", jobId: "job-1" }); + assert.deepEqual(resumeDispatchDecision(captured, null, drain), { action: "abort-stale" }); + assert.deepEqual(resumeDispatchDecision(captured, { ...captured, runId: "run-y" }, drain), { action: "abort-stale" }); + assert.deepEqual(resumeDispatchDecision(captured, { ...captured }, { recoveryOnly: false }), { action: "post" }); +}); + test("a stale resume queued behind a newer run neither posts nor touches the newer record", async () => { const locks = fakeLockManager(); const signal = new AbortController().signal; From fad9954c9b5ccb190e06bc6eb82576f244afb0d1 Mon Sep 17 00:00:00 2001 From: randomblocker <130692587+randomblocker@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:24:32 +0200 Subject: [PATCH 3/3] Clarify gateway drain rollout and pin the client dispatch guard Add the gateway cutover procedure, accurate bundle and validation guidance, and a reconciliation request link. Exercise runAgent directly despite disabled controls in the recovery browser pass. Co-Authored-By: Codex --- docs/gateway-demo-cutover.md | 38 ++++++++++++++++++ .../dacs-directory/README.md | 2 +- .../dacs-directory/e2e/README.md | 2 +- .../e2e/try-dacs-recovery.spec.ts | 40 ++++++++++++++++++- .../dacs-directory/src/components/TryDacs.tsx | 18 +++++---- .../src/components/gateway-demo-recovery.ts | 3 ++ 6 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 docs/gateway-demo-cutover.md diff --git a/docs/gateway-demo-cutover.md b/docs/gateway-demo-cutover.md new file mode 100644 index 0000000..f91655b --- /dev/null +++ b/docs/gateway-demo-cutover.md @@ -0,0 +1,38 @@ +# Gateway demo cutover + +Keep the Community drain PR in draft until the gateway-owned buyer UI is +deployed and smoke-tested, and an operator can look up a job by its +`Idempotency-Key`. The browser's reported run id is that key. A run with no +recorded job id needs operator reconciliation; the drain never re-sends it. + +1. Before or atomically with the Community drain deployment, enable a gateway + gate that prevents the retiring Community origin from creating work through + `POST /demo/butler` and `POST /demo/procurement`. Preserve read-only + `GET /demo/procurement/:jobId` recovery. The gateway must check actual POSTs + before job creation, including when a browser has cached an earlier successful + CORS preflight. An old client deletes its recovery record on an ordinary 4xx + response, so a blocked replay must instead fail as a network/CORS error or + a retryable 503. Rejecting only the response's CORS headers after creating + work is too late. +2. In the gateway's isolated integration environment, load the pre-drain + Community client before activating that gate. Verify that fresh Butler and + procurement starts, and procurement replays with unknown idempotency keys, + create zero jobs and perform zero payments. Verify that a blocked replay + retains the browser's record, including with a previously cached POST + preflight, and that a recorded job id can still be read. Test the gateway-owned + UI separately so the origin gate does not prevent its normal operation. +3. Build and deploy Community with + `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1`. The flag is inlined by `next build`; + changing it requires a rebuild. It controls pages loaded after deployment. + Already-open tabs keep their previous JavaScript until reloaded, which is why + the gateway gate is required. +4. Wait at least 12 minutes and reconcile gateway-visible jobs and any run ids + users report. Confirm the gateway gate is still active and check again for + Community-origin jobs immediately before removing the old pages. The wait + bounds dispatched jobs; it cannot bound when an idle old tab sends a request. +5. Only after those checks pass, merge and deploy Community PR #84. Keep the + gateway gate active after page removal to cover old tabs. + +Community's mocked Playwright recovery pass verifies the newly loaded client +bundle. It does not verify the gateway gate. The gateway service and its +deployment configuration are maintained outside this repository. diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 0be1724..5883cba 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -127,7 +127,7 @@ vendor directory. | `DACS_TRUST_PROXY` | No | Set to `1` only behind a trusted proxy that overwrites client-IP headers; otherwise the in-process rate limiter is disabled and the deployment must enforce its edge limit | | `NEXT_PUBLIC_DIRECTORY_URL` | Production | Public origin used by canonical URLs, sitemap, `llms.txt`, and machine-discovery documents; defaults to `http://localhost:3400`, which silently poisons production canonical URLs and the sitemap — the server logs a warning when unset in production | | `NEXT_PUBLIC_BUTLER_ORIGIN` | Production | Public HTTPS origin of the DACS agent gateway used by `/try`; defaults to `http://127.0.0.1:8402` only for local development. Railway validates this at build time. | -| `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY` | Temporary drain only | Build-time (inlined by `next build`; rebuild and redeploy to change it). Set to exact `1` only after the gateway-owned demo is live. The old `/try` page then sends no procurement POST at all: it blocks every fresh run, and `Check & resume` only reads runs whose gateway job id the browser recorded. A run without a job id is never re-sent; the page shows its run id for manual reconciliation. Remove the flag by deploying the gateway-demo removal after the drain window. | +| `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY` | Temporary drain only | Build-time (inlined by `next build`; rebuild and redeploy to change it). Set to exact `1` only after the gateway-owned demo is live. Pages loaded after the deploy block fresh runs and send no procurement POST: `Check & resume` only reads runs with a recorded gateway job id. A run without a job id is never re-sent; its run id is shown for manual reconciliation. Before or atomically with this deploy, the gateway must prevent the retiring Community origin from creating work, including requests from tabs loaded before the deploy. Follow the [cutover procedure](../../docs/gateway-demo-cutover.md) before removing the old pages. | The data directory must be persistent and writable in deployments that accept registrations or run the indexer. Never commit `.indexer-seed`, `.indexer-mnemonic`, diff --git a/reference-implementations/dacs-directory/e2e/README.md b/reference-implementations/dacs-directory/e2e/README.md index b471c04..746c5fa 100644 --- a/reference-implementations/dacs-directory/e2e/README.md +++ b/reference-implementations/dacs-directory/e2e/README.md @@ -2,7 +2,7 @@ The Playwright suite has two layers: -- `npm run test:e2e` runs two deterministic passes. The first (`playwright.config.ts`, port 3400) runs the landing-page, seller-registration, mocked `/try` payment-safety and `/try-chat` replay regressions. The second (`playwright.recovery.config.ts`, port 3401) builds `/try` with `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1` and runs `try-dacs-recovery.spec.ts`, which proves the drain sends no procurement POST: fresh starts are disabled, a run with a job id is read, and a run without one goes to manual reconciliation. The default config ignores that spec because it needs the flag. Neither pass spends DEM or USDC; both are safe for CI. +- `npm run test:e2e` runs two deterministic passes. The first (`playwright.config.ts`, port 3400) runs the landing-page, seller-registration, mocked `/try` payment-safety and `/try-chat` replay regressions. The second (`playwright.recovery.config.ts`, port 3401) starts a separate development server for `/try` with `NEXT_PUBLIC_GATEWAY_DEMO_RECOVERY_ONLY=1` and runs `try-dacs-recovery.spec.ts`, which proves the recovery-only page sends no procurement POST: fresh starts are disabled, the dispatch handler refuses even a direct invocation, a run with a job id is read, and a run without one goes to manual reconciliation. The default config ignores that spec because it needs the flag. These mocked checks cover newly loaded pages; gateway enforcement for pre-deployment tabs must be checked separately as described in the [cutover procedure](../../../docs/gateway-demo-cutover.md). Neither pass spends DEM or USDC; both are safe for CI. - `npm run test:e2e:live` contains five serial checks against the live Butler gateway. It is skipped unless the operator explicitly authorizes a capped testnet purchase. ## Install the browser diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts index 1da3e5c..f7f2bfc 100644 --- a/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts +++ b/reference-implementations/dacs-directory/e2e/try-dacs-recovery.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type BrowserContext, type Page } from "@playwright/test"; +import { expect, test, type BrowserContext, type Locator, type Page } from "@playwright/test"; import { PROCUREMENT_RUN_KEY, completedJob, @@ -26,6 +26,41 @@ async function seedStoredRun(page: Page, value: Record) { }, { key: PROCUREMENT_RUN_KEY, value }); } +// Deliberately bypass the disabled DOM button to exercise the component's +// dispatch guard. React's props are used only in this test, with no production +// test hook; a normal click cannot invoke a disabled button's handler. +async function invokeClickHandler(button: Locator) { + await button.evaluate(async (element) => { + const key = Object.keys(element).find((key) => key.startsWith("__reactProps$")); + const props = key ? (element as unknown as Record unknown }>)[key] : undefined; + if (typeof props?.onClick !== "function") throw new Error("React click handler not found"); + await props.onClick(); + }); +} + +test("recovery-only runAgent refuses a direct invocation with a valid purchase form", async ({ context, page }) => { + const posts = await countProcurementPosts(context); + let butlerPosts = 0; + await context.route("**/demo/butler", async (route) => { + if (route.request().method() === "POST") butlerPosts += 1; + await route.abort("blockedbyclient"); + }); + await page.goto("/try"); + const agent = page.locator(".picker-grid").getByRole("button", { name: /Security Auditor/ }); + await expect(agent).toBeDisabled(); + await invokeClickHandler(agent); + await page.getByRole("button", { name: "Load example" }).click(); + await expect(page.locator(".field-error")).toHaveCount(0); + const run = page.locator(".job-box").getByRole("button", { name: /New purchases paused/ }); + await expect(run).toBeDisabled(); + await invokeClickHandler(run); + + await expect(page.locator(".bubble.error")).toContainText("New purchases are paused"); + expect(posts.count).toBe(0); + expect(butlerPosts).toBe(0); + expect(await page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); +}); + test("recovery-only deployment blocks fresh starts but preserves an existing job", async ({ context, page }) => { const posts = await countProcurementPosts(context); await seedStoredRun(page, { @@ -64,6 +99,9 @@ test("recovery-only deployment never re-sends a run whose job id was never recei await page.goto("/try"); await expect(page.locator(".resume-banner[role=alert]")).toContainText("give the operator run id recovery-only-unsent-run"); + await expect(page.getByRole("link", { name: /Ask for run reconciliation/ })).toHaveAttribute( + "href", "https://github.com/DACS-Agent-commerce/Community/issues/new?title=Gateway%20demo%20run%20reconciliation", + ); await page.getByRole("button", { name: /Check & resume/ }).click(); await expect(page.locator(".bubble.error")).toContainText("run id recovery-only-unsent-run"); diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx index 4292229..302497e 100644 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ b/reference-implementations/dacs-directory/src/components/TryDacs.tsx @@ -36,7 +36,7 @@ import { type FieldErrors, } from "./try-dacs-forms.js"; import AgentInputForm from "./try-forms/AgentInputForm.js"; -import { GATEWAY_DEMO_RECOVERY_MESSAGE, gatewayDemoManualReconcileMessage, gatewayDemoRecoveryOnly } from "./gateway-demo-recovery.js"; +import { GATEWAY_DEMO_RECOVERY_HELP_URL, GATEWAY_DEMO_RECOVERY_MESSAGE, gatewayDemoManualReconcileMessage, gatewayDemoRecoveryOnly } from "./gateway-demo-recovery.js"; import { ProcurementLockUnavailableError, parseStoredProcurementRun, resumeDispatchDecision, stageEvents, withExclusiveProcurementLock, type LockRequestor, type StoredProcurementRun } from "./try-dacs-stages.js"; const BUTLER = (process.env.NEXT_PUBLIC_BUTLER_ORIGIN ?? "http://127.0.0.1:8402").replace(/\/$/, ""); @@ -903,9 +903,10 @@ export default function TryDacs() { /** * Reconcile a persisted run after a reload. With a job id this only reads * status. Without one (the reload raced the POST response) it re-POSTs the - * SAME stored input under the SAME idempotency key, so the gateway returns - * the original job if it exists — it can never start a second purchase for - * this run. + * SAME stored input under the SAME idempotency key, except in the recovery-only + * drain, where resumeDispatchDecision returns manual-reconcile. Outside the + * drain the gateway returns the original job if it exists; an unknown key + * can create the first purchase for this run. */ async function resumeStoredRun() { const stored = storedRun; @@ -946,9 +947,11 @@ export default function TryDacs() { if (!response.ok) throw new Error(message(body)); job = parseProcurementJob(body); } else { - // The reload raced the original POST response: re-POST the SAME - // stored input under the SAME key — inside the cross-tab lock, like - // every paid dispatch, and refusing without a real lock manager. + // The reload raced the original POST response: outside the drain, + // re-POST the SAME stored input under the SAME key. In the drain, + // resumeDispatchDecision returns manual-reconcile instead. Both + // decisions run inside the cross-tab lock, refusing without a real + // lock manager. // The lock only SERIALIZES: while this section was queued, another // tab may have dismissed this record and started a new run, so the // section re-validates its captured record against storage NOW and @@ -1092,6 +1095,7 @@ export default function TryDacs() {
A procurement run from this browser is still on record

Started {new Date(storedRun.startedAt).toLocaleString()}{storedRun.jobId ? ` · job ${compact(storedRun.jobId, 8, 6)}` : " · the job id was never received"}. {GATEWAY_DEMO_RECOVERY_ONLY && !storedRun.jobId ? `New purchases are paused, so this run is never re-sent: Check & resume only follows a job id another tab received. Otherwise give the operator run id ${storedRun.runId}.` : "Resuming reconnects under the same idempotency key, so it can never start a second purchase."}

+ {GATEWAY_DEMO_RECOVERY_ONLY && !storedRun.jobId &&

Ask for run reconciliation ↗ — include the run id shown above.

}
diff --git a/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts index 8a59657..251f3a1 100644 --- a/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts +++ b/reference-implementations/dacs-directory/src/components/gateway-demo-recovery.ts @@ -1,6 +1,9 @@ export const GATEWAY_DEMO_RECOVERY_MESSAGE = "New purchases are paused while the buyer demo moves to its gateway-owned origin. Existing runs whose gateway job id this browser received can still be checked and resumed; a run without one is never re-sent and goes to manual reconciliation."; +export const GATEWAY_DEMO_RECOVERY_HELP_URL = + "https://github.com/DACS-Agent-commerce/Community/issues/new?title=Gateway%20demo%20run%20reconciliation"; + /** * Shown when a drain-mode resume finds a record without a job id: the * original POST may never have reached the gateway, so re-sending it could