Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions reference-implementations/dacs-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
2 changes: 1 addition & 1 deletion reference-implementations/dacs-directory/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { name: string; label: string }> = {
"oracle-auto-accept": { name: "oracle-desk", label: "Oracle Desk" },
Expand Down Expand Up @@ -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<string, unknown>;
try { parsed = parseAgentInput(inputValue); }
Expand Down Expand Up @@ -1058,6 +1064,15 @@ export default function TryDacs() {
<p>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.</p>
</section>

{GATEWAY_DEMO_RECOVERY_ONLY && (
<section className="resume-banner" data-testid="gateway-demo-recovery-only" role="status">
<div>
<strong>New purchases are paused during the demo move</strong>
<p>{GATEWAY_DEMO_RECOVERY_MESSAGE}</p>
</div>
</section>
)}

{/* 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. */}
Expand Down Expand Up @@ -1091,15 +1106,15 @@ export default function TryDacs() {
<div className="picker-grid">{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 <button key={agent.name} onClick={() => selectAgent(agent)}><span>{agent.label.slice(0, 1)}</span><div><strong>{agent.label}</strong><small>{profile ? procurementModeLabel(profile.mode) : agent.summary}</small><em>{profile ? `${liveRails} · ${profile.timing.healthyMinSec}–${profile.timing.healthyMaxSec}s` : "live"}</em></div><i>→</i></button>;
return <button key={agent.name} disabled={GATEWAY_DEMO_RECOVERY_ONLY} onClick={() => selectAgent(agent)}><span>{agent.label.slice(0, 1)}</span><div><strong>{agent.label}</strong><small>{profile ? procurementModeLabel(profile.mode) : agent.summary}</small><em>{GATEWAY_DEMO_RECOVERY_ONLY ? "new runs paused" : profile ? `${liveRails} · ${profile.timing.healthyMinSec}–${profile.timing.healthyMaxSec}s` : "live"}</em></div><i>→</i></button>;
})}</div>
</div>
) : phase === "error" && plan ? (
<div className="job-box recovery-box">
{isProcurementSel && procurementJob?.status === "failed" && procurementJob.failedBeforePayment === true ? (
<>
<div><strong>Procurement failed before any payment</strong><small>The gateway confirms no money moved (its reason is shown above). Retrying starts a fresh purchase attempt with a new idempotency key.</small></div>
<div className="job-actions"><button className="ghost-btn" onClick={() => { setPhase("idle"); setPlan(null); setSelectedProfileId(null); setError(""); setProcurementJob(null); }}>Choose another procurement</button><button className="ghost-btn" onClick={() => { setPhase("ready"); setProcurementJob(null); setError(""); }}>Edit details</button><button className="btn try-primary" onClick={runAgent}>Retry the purchase <span>→</span></button></div>
<div className="job-actions"><button className="ghost-btn" onClick={() => { setPhase("idle"); setPlan(null); setSelectedProfileId(null); setError(""); setProcurementJob(null); }}>Choose another procurement</button><button className="ghost-btn" onClick={() => { setPhase("ready"); setProcurementJob(null); setError(""); }}>Edit details</button><button className="btn try-primary" disabled={GATEWAY_DEMO_RECOVERY_ONLY} onClick={runAgent}>{GATEWAY_DEMO_RECOVERY_ONLY ? "New purchases paused" : "Retry the purchase"} <span>→</span></button></div>
</>
) : isProcurementSel && procurementJob?.status === "failed" ? (
<>
Expand All @@ -1114,18 +1129,18 @@ export default function TryDacs() {
) : isProcurementSel ? (
<>
<div><strong>Procurement stopped safely</strong><small>Retrying reuses this run’s idempotency key, so the gateway resumes the existing job rather than starting a second paid purchase.</small></div>
<div className="job-actions"><button className="ghost-btn" onClick={() => { setPhase("idle"); setPlan(null); setSelectedProfileId(null); setError(""); setProcurementJob(null); }}>Choose another procurement</button><button className="btn try-primary" onClick={runAgent}>Retry this run <span>→</span></button></div>
<div className="job-actions"><button className="ghost-btn" onClick={() => { setPhase("idle"); setPlan(null); setSelectedProfileId(null); setError(""); setProcurementJob(null); }}>Choose another procurement</button><button className="btn try-primary" disabled={GATEWAY_DEMO_RECOVERY_ONLY} onClick={runAgent}>{GATEWAY_DEMO_RECOVERY_ONLY ? "Use Check & resume" : "Retry this run"} <span>→</span></button></div>
</>
) : (
<>
<div><strong>{plan.butler.label} stopped safely</strong><small>Your entered job details are still available.</small></div>
<div className="job-actions"><button className="ghost-btn" onClick={() => { setPhase("idle"); setPlan(null); setError(""); }}>Choose another agent</button><button className="ghost-btn" onClick={() => setPhase("ready")}>Edit details</button><button className="btn try-primary" onClick={runAgent}>Retry this agent <span>→</span></button></div>
<div className="job-actions"><button className="ghost-btn" onClick={() => { setPhase("idle"); setPlan(null); setError(""); }}>Choose another agent</button><button className="ghost-btn" onClick={() => setPhase("ready")}>Edit details</button><button className="btn try-primary" disabled={GATEWAY_DEMO_RECOVERY_ONLY} onClick={runAgent}>{GATEWAY_DEMO_RECOVERY_ONLY ? "New runs paused" : "Retry this agent"} <span>→</span></button></div>
</>
)}
</div>
) : plan && phase === "ready" && selected ? (
<div className="job-box">
<div className="job-head"><div><span>{selectedProfile ? procurementModeLabel(selectedProfile.mode) : "Job details"}</span><small>{plan.inputNote}</small></div><span className={`badge ${inputIsValid ? "ok" : "err"}`}>{inputIsValid ? `ready · ${paymentRailLabel(selectedPaymentRail)}` : "fields need attention"}</span></div>
<div className="job-head"><div><span>{selectedProfile ? procurementModeLabel(selectedProfile.mode) : "Job details"}</span><small>{plan.inputNote}</small></div><span className={`badge ${inputIsValid && !GATEWAY_DEMO_RECOVERY_ONLY ? "ok" : "err"}`}>{GATEWAY_DEMO_RECOVERY_ONLY ? "recovery only" : inputIsValid ? `ready · ${paymentRailLabel(selectedPaymentRail)}` : "fields need attention"}</span></div>
{selectedProfile && <div className="rail-picker" role="group" aria-label="Payment rail">
<div className="rail-picker-head"><strong>Choose payment rail</strong><span>This changes the real asset and settlement network.</span></div>
<div className="rail-options">{selectedProfile.paymentRails.map((rail) => {
Expand All @@ -1148,7 +1163,7 @@ export default function TryDacs() {
<div className="job-actions">
<button className="ghost-btn" onClick={() => { setPhase("idle"); setPlan(null); setSelectedProfileId(null); }}>Start over</button>
<button className="ghost-btn" onClick={loadExample}>Load example</button>
<button className="btn try-primary" onClick={runAgent} disabled={!inputIsValid}>Run the full deal <span>→</span></button>
<button className="btn try-primary" onClick={runAgent} disabled={!inputIsValid || GATEWAY_DEMO_RECOVERY_ONLY}>{GATEWAY_DEMO_RECOVERY_ONLY ? "New purchases paused" : "Run the full deal"} <span>→</span></button>
</div>
</div>
) : phase === "running" && isProcurementSel ? (
Expand Down Expand Up @@ -1222,7 +1237,7 @@ export default function TryDacs() {

<section className="try-agents"><div className="try-section-head"><div><span>THREE WAYS TO PROCURE</span><h2>Production agents, DEM or x402, full DACS</h2></div><p>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.</p></div><div className="try-agent-grid">{profiles.map((profile, index) => {
const agent = procurementProfileCard(profile, defaultPaymentRail(profile));
return <button key={profile.id} onClick={() => selectAgent(agent)}><span>0{index + 1} · {procurementModeLabel(profile.mode)}</span><strong>{profile.agentName}</strong><p>{profile.summary}</p><i>Run this procurement →</i></button>;
return <button key={profile.id} disabled={GATEWAY_DEMO_RECOVERY_ONLY} onClick={() => selectAgent(agent)}><span>0{index + 1} · {procurementModeLabel(profile.mode)}</span><strong>{profile.agentName}</strong><p>{profile.summary}</p><i>{GATEWAY_DEMO_RECOVERY_ONLY ? "New runs paused" : "Run this procurement →"}</i></button>;
})}</div></section>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
@@ -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/);
});