diff --git a/.github/workflows/dacs-directory-ci.yml b/.github/workflows/dacs-directory-ci.yml index 844cbff..4da5eec 100644 --- a/.github/workflows/dacs-directory-ci.yml +++ b/.github/workflows/dacs-directory-ci.yml @@ -32,8 +32,6 @@ jobs: # Keeps canonical URLs / sitemap deterministic during the build instead # of falling back to localhost (which the build warns about). NEXT_PUBLIC_DIRECTORY_URL: https://directory.ci.local - # Exercise the same production build-time path as the public /try page. - NEXT_PUBLIC_BUTLER_ORIGIN: https://agents.ci.local steps: - uses: actions/checkout@v7 @@ -57,7 +55,7 @@ jobs: - name: Test (includes seed smoke) run: npm test - - name: Validate production origins + - name: Validate public directory origin run: npm run check:deploy-config - name: Production build diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 48b8e3a..b5e385d 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -124,25 +124,22 @@ vendor directory. | `DACS_RECIPE_POLICIES` | For tier elevation | JSON array of version-pinned DACS-2 recipe policies (`scheme`, `recipeVersion`, `methods`, `defaultMaxAgeSec`, `availability`, `trustedResultSigners`); absent/invalid policy fails closed to `self-declared` | | `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. | The data directory must be persistent and writable in deployments that accept registrations or run the indexer. Never commit `.indexer-seed`, `.indexer-mnemonic`, or an admin token. The project is pinned to Node 22 through `.nvmrc` and `package.json` engines. Before -promoting a deployment, verify both gateway reachability and its explicit CORS allowlist: +promoting a deployment, validate the public canonical origin: ```bash NEXT_PUBLIC_DIRECTORY_URL=https://directory.example \ -NEXT_PUBLIC_BUTLER_ORIGIN=https://agents.example \ -npm run check:butler +npm run check:deploy-config ``` -The probe fails unless the gateway returns at least one Butler agent, its -`Access-Control-Allow-Origin` exactly matches the directory origin, and browser -preflights for both execution routes allow `POST` with `content-type`. Configure -the gateway's `BUTLER_ALLOWED_ORIGINS` with that directory origin before running it. +The deployment check requires one credential-free HTTPS origin with no path, query, +or fragment, so generated canonical URLs and discovery documents cannot silently point +at localhost or a different base path. ## Human and agent discovery diff --git a/reference-implementations/dacs-directory/app/globals.css b/reference-implementations/dacs-directory/app/globals.css index bcee314..ea9b288 100644 --- a/reference-implementations/dacs-directory/app/globals.css +++ b/reference-implementations/dacs-directory/app/globals.css @@ -95,9 +95,9 @@ main { max-width: 1080px; margin: 0 auto; padding: 48px 24px 96px; } .nav-logo b { color: var(--accent); font-weight: 700; } .nav-links { display: flex; align-items: center; gap: 20px; margin-left: auto; color: var(--text-secondary); font-size: 0.875rem; text-transform: lowercase; } .nav-links a:hover { color: var(--text-primary); } -.nav-links .nav-try { color: var(--accent-strong); font-weight: 700; padding: 7px 12px; border: 1px solid var(--accent-border); border-radius: var(--radius-button); background: var(--accent-soft); } -.nav-links .nav-try:hover { color: #fff; background: var(--accent); border-color: var(--accent); } -.nav-try span { margin-left: 3px; font-size: 1rem; } +.nav-links .nav-featured { color: var(--accent-strong); font-weight: 700; padding: 7px 12px; border: 1px solid var(--accent-border); border-radius: var(--radius-button); background: var(--accent-soft); } +.nav-links .nav-featured:hover { color: #fff; background: var(--accent); border-color: var(--accent); } +.nav-featured span { margin-left: 3px; font-size: 1rem; } /* ── Type scale (site tokens) ─────────────────────────────────────────────── */ .h1 { font-size: 2.25rem; line-height: 1.1; letter-spacing: -0.015em; font-weight: 700; margin: 28px 0 8px; } @@ -488,318 +488,6 @@ textarea.form-control { min-height: 112px; resize: vertical; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } } - -/* ── Try DACS: guided Butler experience ──────────────────────────────────── */ -.try-page { --try-ink: var(--text-primary); } -.try-hero { min-height: 390px; display: grid; grid-template-columns: 1.35fr .65fr; align-items: flex-end; gap: 54px; padding: 34px 0 58px; } -.try-kicker { display: flex; align-items: center; gap: 9px; color: var(--accent-strong); font: 700 .68rem/1 var(--font-scp, monospace); letter-spacing: .14em; margin-bottom: 24px; } -.try-kicker i { display: block; width: 8px; height: 8px; border-radius: 50%; background: var(--green-strong); box-shadow: 0 0 0 5px var(--green-soft); } -.try-hero h1 { font-size: clamp(3.5rem, 7vw, 6.9rem); line-height: .91; letter-spacing: -.065em; margin: 0; } -.try-hero h1 em { color: var(--accent); font-family: Georgia, serif; font-weight: 400; } -.try-hero > p { color: var(--text-secondary); font-size: clamp(1rem, 1.6vw, 1.3rem); line-height: 1.55; padding-bottom: 8px; } - -.try-shell { display: grid; grid-template-columns: minmax(0, 1fr) 340px; border: 1px solid var(--border-hover); border-radius: 20px; overflow: hidden; background: var(--bg-card); box-shadow: 0 26px 70px rgba(20, 10, 45, .09); } -.butler-chat { min-width: 0; } -.chat-head { min-height: 76px; padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 13px; } -.butler-avatar { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; color: #fff; background: linear-gradient(145deg, var(--accent), #4f46e5); font-weight: 700; box-shadow: 0 7px 18px var(--accent-border); } -.chat-head strong, .chat-head small { display: block; }.chat-head strong { font-size: .92rem; }.chat-head small { color: var(--text-muted); font-size: .72rem; margin-top: 2px; } -.chat-body { min-height: 350px; max-height: 620px; overflow: auto; padding: 26px 24px; background: linear-gradient(180deg, var(--bg-subtle), var(--bg-card)); display: flex; flex-direction: column; gap: 17px; } -.bubble { max-width: 78%; padding: 15px 17px; border: 1px solid var(--border); border-radius: 4px 16px 16px 16px; background: var(--bg-card); box-shadow: 0 5px 16px rgba(10, 10, 10, .035); } -.bubble > span { display: block; color: var(--accent-strong); font: 700 .62rem/1 var(--font-scp, monospace); letter-spacing: .1em; text-transform: uppercase; margin-bottom: 8px; } -.bubble p { color: var(--text-secondary); font-size: .91rem; line-height: 1.55; }.bubble p strong { color: var(--text-primary); } -.bubble.human { align-self: flex-end; border-radius: 16px 4px 16px 16px; background: var(--accent); border-color: var(--accent); }.bubble.human > span,.bubble.human p { color: #fff; } -.bubble.error { border-color: var(--red-border); background: var(--red-soft); }.bubble.error > span,.bubble.error p { color: var(--red-strong); } -.bubble.thinking b { display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); margin-right: 4px; animation: try-thinking 1.1s infinite ease-in-out; }.bubble.thinking b:nth-child(2){animation-delay:.12s}.bubble.thinking b:nth-child(3){animation-delay:.24s} -@keyframes try-thinking { 0%,70%,100%{transform:translateY(0);opacity:.35}35%{transform:translateY(-4px);opacity:1} } -.reasoning-chip { display: inline-flex; margin-top: 11px; padding: 4px 8px; border-radius: 5px; background: var(--cyan-soft); color: var(--cyan-strong); border: 1px solid var(--cyan-border); font: 600 .66rem var(--font-scp, monospace); } - -.agent-picker { padding: 20px 24px 24px; border-top: 1px solid var(--border); background: var(--bg-card); } -.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }.picker-head strong { font-size: .78rem; }.picker-head span { color: var(--text-muted); font: .65rem var(--font-scp, monospace); } -.picker-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } -.picker-grid button { min-width: 0; min-height: 96px; display: grid; grid-template-columns: 30px 1fr 12px; align-items: center; gap: 9px; padding: 11px; text-align: left; color: var(--text-primary); background: var(--bg-subtle); border: 1px solid var(--border); border-radius: 10px; cursor: pointer; font-family: inherit; }.picker-grid button:hover { border-color: var(--accent-border); background: var(--accent-soft); }.picker-grid button > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; color: var(--accent-strong); background: var(--accent-soft); font-size: .7rem; font-weight: 700; }.picker-grid strong,.picker-grid small,.picker-grid em { display: block; }.picker-grid strong { font-size: .7rem; }.picker-grid small { color: var(--text-muted); font-size: .58rem; line-height: 1.3; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }.picker-grid em { margin-top: 5px; color: var(--green-strong); font: 600 .52rem var(--font-scp, monospace); font-style: normal; }.picker-grid i { color: var(--accent-strong); font-style: normal; } - -.ask-box,.job-box { padding: 22px 24px 24px; border-top: 1px solid var(--border); background: var(--bg-card); } -.ask-box label { display: block; font-size: .76rem; font-weight: 700; margin-bottom: 9px; } -.ask-box textarea,.job-box textarea { width: 100%; resize: vertical; color: var(--text-primary); background: var(--bg-subtle); border: 1px solid var(--border-hover); border-radius: 11px; outline: none; padding: 14px 15px; font: inherit; } -.ask-box textarea:focus,.job-box textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } -.ask-box .try-primary { margin-top: 12px; margin-left: auto; } -.try-primary { justify-content: space-between; min-width: 175px; } -.job-head,.job-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }.job-head { margin-bottom: 12px; }.job-head span { display: block; font-size: .76rem; font-weight: 700; }.job-head small { display: block; max-width: 520px; color: var(--text-muted); font-size: .68rem; margin-top: 3px; }.job-box textarea { font: .75rem/1.6 var(--font-scp, monospace); }.job-actions { margin-top: 12px; } -.rail-picker { margin: 14px 0 18px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-subtle); }.rail-picker-head { display: flex; align-items: baseline; justify-content: space-between; gap: 14px; margin-bottom: 10px; }.rail-picker-head strong { color: var(--text-primary); font-size: .74rem; }.rail-picker-head span { color: var(--text-muted); font-size: .64rem; }.rail-options { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }.rail-options button { display: grid; grid-template-columns: 26px 1fr; gap: 9px; width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 10px; color: inherit; background: var(--bg-card); text-align: left; cursor: pointer; }.rail-options button:hover:not(:disabled) { border-color: var(--border-hover); }.rail-options button.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }.rail-options button:disabled { cursor: not-allowed; opacity: .55; }.rail-options button > i { display: grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--border-hover); border-radius: 50%; color: white; font-style: normal; font-size: .65rem; }.rail-options button.selected > i { border-color: var(--accent); background: var(--accent); }.rail-options button strong,.rail-options button small,.rail-options button em { display: block; }.rail-options button strong { color: var(--text-primary); font-size: .73rem; }.rail-options button small { margin-top: 3px; color: var(--text-secondary); font-size: .63rem; }.rail-options button em { margin-top: 6px; color: var(--text-muted); font-size: .57rem; font-style: normal; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.rail-disclosure,.rail-governance { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 11px 12px; color: var(--text-secondary); background: rgba(180,95,0,.07); }.rail-disclosure { margin-top: 9px; border: 1px solid rgba(180,95,0,.18); border-radius: 9px; }.rail-disclosure.canonical,.rail-governance.canonical { background: rgba(16,132,91,.07); }.rail-disclosure strong,.rail-disclosure small,.rail-governance strong,.rail-governance span { display: block; }.rail-disclosure strong,.rail-governance strong { color: var(--text-primary); font-size: .65rem; }.rail-disclosure small,.rail-governance span { margin-top: 3px; color: var(--text-muted); font-size: .6rem; }.rail-disclosure a,.rail-governance a { flex: none; color: var(--accent); font-size: .62rem; font-weight: 700; text-decoration: none; }.rail-governance { border-top: 1px solid var(--border); padding: 12px 18px; } -.ghost-btn { border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); border-radius: var(--radius-button); padding: 9px 14px; cursor: pointer; font: 600 .78rem inherit; }.ghost-btn:hover { color: var(--text-primary); border-color: var(--border-hover); }.ghost-btn:disabled { opacity: .5; } - -.journey { border-left: 1px solid var(--border); background: var(--bg-tinted); min-height: 640px; } -.journey-head { height: 76px; padding: 0 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; color: var(--text-muted); font: 700 .62rem var(--font-scp, monospace); letter-spacing: .1em; }.journey-head span:last-child { color: var(--green-strong); } -.journey-intro { padding: 14px 20px 6px; color: var(--text-muted); font-size: .68rem; line-height: 1.55; }.journey-intro em { color: var(--text-secondary); font-style: normal; font: 700 .6rem var(--font-scp, monospace); letter-spacing: .02em; } -.journey-steps { position: relative; padding: 6px 0 4px; } -.journey-step { position: relative; display: grid; grid-template-columns: 40px 1fr; gap: 4px; padding: 12px 18px 12px 14px; opacity: .55; transition: .25s; } -.journey-step::before { content: ""; position: absolute; left: 27px; top: 0; bottom: 0; width: 2px; background: var(--border); } -.journey-step:first-child::before { top: 16px; } -.journey-step:last-child::before { bottom: auto; height: 16px; } -.journey-step.active, .journey-step.complete, .journey-step.warning { opacity: 1; } -.journey-step.skipped { opacity: .78; } -.journey-index { position: relative; z-index: 1; width: 28px; height: 28px; margin-top: 1px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-hover); background: var(--bg-card); color: var(--text-muted); font: 700 .68rem var(--font-scp, monospace); } -.journey-step.complete .journey-index { border-color: var(--green-strong); background: var(--green-soft); color: var(--green-strong); } -.journey-step.active .journey-index { border-color: var(--accent); color: var(--accent-strong); animation: livePulse 1.6s infinite; } -.journey-step.warning .journey-index { border-color: var(--red-strong); background: var(--red-soft); color: var(--red-strong); } -.journey-step.skipped .journey-index { border-style: dashed; } -.stage-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }.stage-title strong { font-size: .82rem; } -.dacs-tag { font: 700 .54rem var(--font-scp, monospace); letter-spacing: .06em; color: var(--accent-strong); border: 1px solid var(--accent-border); background: var(--accent-soft); border-radius: 999px; padding: 1px 7px; } -.journey-step > div > p { color: var(--text-muted); font-size: .68rem; line-height: 1.45; margin-top: 2px; } -.phase-output { min-width: 0; margin-top: 8px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); }.phase-output span,.phase-output b,.phase-output small { display: block; }.phase-output span { color: var(--text-muted); font: 700 .54rem var(--font-scp, monospace); letter-spacing: .08em; text-transform: uppercase; }.phase-output b { overflow-wrap: anywhere; margin-top: 4px; color: var(--text-secondary); font-size: .66rem; line-height: 1.35; }.phase-output small { display: -webkit-box; overflow: hidden; margin-top: 4px; color: var(--text-muted); font-size: .59rem; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }.phase-output.complete { border-color: var(--green-border); background: var(--green-soft); }.phase-output.complete span,.phase-output.complete b { color: var(--green-strong); }.phase-output.active { border-color: var(--accent-border); background: var(--accent-soft); }.phase-output.active span,.phase-output.active b { color: var(--accent-strong); }.phase-output.warning { border-color: var(--red-border); background: var(--red-soft); }.phase-output.warning span,.phase-output.warning b { color: var(--red-strong); }.phase-output.skipped { border-style: dashed; } -.stage-txs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }.stage-txs a, .stage-txs code { font: 600 .56rem var(--font-scp, monospace); border: 1px solid var(--border); background: var(--bg-tinted); border-radius: 6px; padding: 2px 7px; color: var(--text-secondary); text-decoration: none; }.stage-txs a { color: var(--accent-strong); border-color: var(--accent-border); }.stage-txs a:hover { background: var(--accent-soft); } -.stage-help { margin-top: 7px; }.stage-help summary { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: .62rem; list-style: none; }.stage-help summary::-webkit-details-marker { display: none; }.stage-help summary::before { content: "?"; display: grid; place-items: center; width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--border-hover); font: 700 .54rem var(--font-scp, monospace); }.stage-help summary:hover, .stage-help[open] summary { color: var(--text-secondary); }.stage-help p { margin-top: 6px; padding: 9px 11px; border-left: 2px solid var(--accent-border); background: var(--bg-card); border-radius: 0 8px 8px 0; color: var(--text-muted); font-size: .64rem; line-height: 1.55; }.stage-help p b { display: block; margin-top: 5px; color: var(--text-secondary); font-weight: 600; } -.chain-activity { margin: 8px 16px 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--bg-card); overflow: hidden; } -.chain-activity-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 11px 12px 0; color: var(--text-muted); font: 700 .58rem var(--font-scp, monospace); letter-spacing: .08em; }.chain-activity-head span:last-child { color: var(--green-strong); white-space: nowrap; } -.chain-activity > p { padding: 5px 12px 9px; color: var(--text-muted); font-size: .62rem; line-height: 1.45; } -.chain-row { display: grid; grid-template-columns: 24px 1fr auto; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); align-items: center; }.chain-row strong { display: block; font-size: .63rem; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; color: var(--text-secondary); }.chain-row small { display: block; margin-top: 2px; color: var(--text-muted); font: 500 .55rem var(--font-scp, monospace); overflow-wrap: anywhere; }.chain-index { color: var(--text-muted); font: 600 .6rem var(--font-scp, monospace); }.chain-row a { font: 600 .58rem var(--font-scp, monospace); color: var(--accent-strong); white-space: nowrap; text-decoration: none; }.chain-row a:hover { text-decoration: underline; }.chain-row .muted-text { font: 500 .56rem var(--font-scp, monospace); } -.journey-note { margin: 8px 16px 20px; padding: 15px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 11px; }.journey-note strong { font-size: .72rem; }.journey-note p { color: var(--text-muted); font-size: .68rem; line-height: 1.5; margin-top: 5px; } - -.resume-banner { display: flex; gap: 16px; align-items: center; justify-content: space-between; flex-wrap: wrap; margin-bottom: 16px; padding: 14px 16px; border: 1px solid var(--accent-border); border-radius: 10px; background: var(--accent-soft); } -.resume-banner strong { display: block; } -.resume-banner p { margin: 4px 0 0; font-size: 0.8rem; color: var(--text-secondary); max-width: 62ch; } -.resume-actions { display: flex; gap: 10px; flex-wrap: wrap; } -.resume-actions .btn { white-space: nowrap; } - -.try-result { margin-top: 36px; border: 1px solid var(--border); border-radius: 17px; background: var(--bg-card); overflow: hidden; }.result-title { padding: 19px 22px; display: flex; justify-content: space-between; align-items: center; gap: 20px; }.result-title h2 { display: inline; margin-left: 10px; font-size: 1rem; }.result-title small { display: block; margin-top: 5px; color: var(--text-muted); font: .65rem var(--font-scp, monospace); }.try-result details { border-top: 1px solid var(--border); }.try-result summary { cursor: pointer; padding: 14px 22px; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }.try-result pre { max-height: 580px; overflow: auto; margin: 0; padding: 20px 22px; color: var(--text-secondary); background: var(--bg-subtle); border-top: 1px solid var(--border); font: .72rem/1.6 var(--font-scp, monospace); } -.live-flow { border-top: 1px solid var(--border); padding: 22px 24px 26px; background: var(--bg-card); }.live-flow-head { display: flex; justify-content: space-between; align-items: center; gap: 14px; margin-bottom: 18px; }.live-flow-head div { color: var(--accent); font-size: .72rem; font-weight: 800; letter-spacing: .08em; }.live-flow-head small { color: var(--text-muted); font-size: .68rem; }.live-pulse { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(105,62,255,.4); animation: livePulse 1.5s infinite; }.live-events { display: grid; gap: 2px; }.live-events > div { display: grid; grid-template-columns: 27px 1fr; gap: 9px; align-items: start; padding: 7px 0; color: var(--text-muted); }.live-events i { display: grid; place-items: center; width: 21px; height: 21px; border-radius: 50%; border: 1px solid var(--border-hover); font-style: normal; font-size: .7rem; }.live-events strong,.live-events small { display: block; }.live-events strong { color: var(--text-secondary); font-size: .75rem; line-height: 1.35; }.live-events small { margin-top: 3px; font: .62rem/1.3 var(--font-scp, monospace); }.live-events .done i { color: #10845b; border-color: rgba(16,132,91,.25); background: rgba(16,132,91,.08); }.live-events .active i { color: var(--accent); border-color: var(--accent); animation: livePulse 1.5s infinite; }.working-box { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--text-secondary); font-size: .8rem; }.working-box strong,.working-box small,.recovery-box strong,.recovery-box small { display: block; }.working-box small,.recovery-box small { margin-top: 4px; color: var(--text-muted); font: .65rem var(--font-scp, monospace); }.live-flow-actions { display: flex; justify-content: flex-end; margin-top: 16px; }.recovery-box > div:first-child { margin-bottom: 14px; } -.receipt-status { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 22px; border-top: 1px solid var(--border); background: var(--bg-subtle); }.receipt-status > div:first-child { min-width: 0; }.receipt-status strong,.receipt-status small { display: block; }.receipt-status strong { color: var(--text-primary); font-size: .76rem; text-transform: capitalize; }.receipt-status small { max-width: 680px; margin-top: 4px; color: var(--text-muted); font-size: .66rem; line-height: 1.45; }.receipt-status p { margin-top: 7px; color: var(--red-strong); font-size: .68rem; }.receipt-dot { display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--text-muted); }.receipt-confirmed .receipt-dot { background: var(--green-strong); }.receipt-failed .receipt-dot { background: var(--red-strong); }.receipt-actions { display: flex; flex: none; align-items: center; gap: 8px; }.receipt-actions a { text-decoration: none; } -@keyframes livePulse { 0% { box-shadow: 0 0 0 0 rgba(105,62,255,.35); } 70% { box-shadow: 0 0 0 7px rgba(105,62,255,0); } 100% { box-shadow: 0 0 0 0 rgba(105,62,255,0); } } -.full-proc-result { background: var(--bg-subtle); }.full-proc-result > .result-title { background: var(--bg-card); border-bottom: 1px solid var(--border); }.proc-report { padding: 22px; display: grid; gap: 18px; }.proc-summary { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--border); }.proc-summary > div { min-width: 0; padding: 17px; background: var(--bg-card); }.proc-summary span,.proc-panel-head span,.tx-link small { display: block; color: var(--text-muted); font-size: .61rem; font-weight: 800; letter-spacing: .08em; }.proc-summary strong { display: block; overflow-wrap: anywhere; margin-top: 7px; color: var(--text-primary); font-size: .82rem; }.success-text { color: #10845b !important; }.error-text { color: var(--red-strong) !important; }.muted-text { color: var(--text-muted) !important; }.proc-panel { overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); }.proc-panel-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 17px 18px; border-bottom: 1px solid var(--border); }.proc-panel-head h3 { margin-top: 4px; color: var(--text-primary); font-size: .9rem; }.payment-panel { border-color: rgba(16,132,91,.28); }.payment-panel.unverified-panel { border-color: var(--red-border); }.tx-link { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 17px 18px; background: rgba(16,132,91,.045); color: inherit; text-decoration: none; border-bottom: 1px solid var(--border); }.tx-link.missing-evidence { background: var(--red-soft); }.tx-link span { min-width: 0; }.tx-link code { display: block; overflow: hidden; margin-top: 6px; color: var(--text-primary); font-size: .72rem; text-overflow: ellipsis; }.tx-link b { flex: none; color: #10845b; font-size: .7rem; }.party-row { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 12px 18px; color: var(--text-muted); font-size: .65rem; }.party-row code { margin-left: 4px; color: var(--text-secondary); }.party-row i { color: var(--accent); font-style: normal; }.candidate-row { display: grid; grid-template-columns: 1.5fr .55fr .65fr 1.3fr; gap: 12px; align-items: center; padding: 13px 18px; border-bottom: 1px solid var(--border); color: var(--text-secondary); font-size: .7rem; }.candidate-row:last-child { border-bottom: 0; }.candidate-row strong,.candidate-row small { display: block; }.candidate-row small { margin-top: 3px; color: var(--text-muted); font: .59rem var(--font-scp, monospace); }.candidate-head { color: var(--text-muted); background: var(--bg-subtle); font-size: .6rem; font-weight: 800; letter-spacing: .06em; }.finding-list article { display: grid; grid-template-columns: 74px 1fr; gap: 14px; padding: 16px 18px; border-bottom: 1px solid var(--border); }.finding-list article:last-child { border-bottom: 0; }.severity { align-self: start; padding: 5px 7px; border-radius: 6px; background: rgba(180,95,0,.1); color: #9a5300; text-align: center; font-size: .6rem; font-weight: 800; text-transform: uppercase; }.severity.high,.severity.critical { color: #b72f3c; background: rgba(183,47,60,.1); }.severity.low,.severity.info { color: #2769a8; background: rgba(39,105,168,.1); }.finding-list strong { color: var(--text-primary); font-size: .76rem; }.finding-list p { margin: 5px 0; color: var(--text-secondary); font-size: .7rem; line-height: 1.5; }.finding-list code { color: var(--text-muted); font-size: .62rem; }.empty-report { padding: 18px; color: var(--text-muted); font-size: .74rem; }.verify-grid { display: grid; grid-template-columns: repeat(4,1fr); }.verify-grid > div { display: flex; gap: 10px; align-items: center; padding: 17px; border-right: 1px solid var(--border); }.verify-grid > div:last-child { border-right: 0; }.verify-grid i { display: grid; place-items: center; flex: 0 0 24px; height: 24px; border-radius: 50%; color: #10845b; background: rgba(16,132,91,.09); font-style: normal; font-size: .7rem; }.verify-grid .failed-check i { color: var(--red-strong); background: var(--red-soft); }.verify-grid .failed-check strong { color: var(--red-strong); }.verify-grid span,.verify-grid strong { display: block; }.verify-grid span { color: var(--text-muted); font-size: .62rem; }.verify-grid strong { margin-top: 3px; color: var(--text-primary); font-size: .7rem; }.receipt-row { display: grid; grid-template-columns: 32px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--border); }.receipt-index { color: var(--text-muted); font: .62rem var(--font-scp, monospace); }.receipt-row strong,.receipt-row small { display: block; }.receipt-row strong { color: var(--text-secondary); font-size: .7rem; }.receipt-row small { overflow: hidden; margin-top: 3px; color: var(--text-muted); font: .6rem var(--font-scp, monospace); text-overflow: ellipsis; }.receipt-row a { color: var(--accent); font: .63rem var(--font-scp, monospace); text-decoration: none; }.anchor-details,.raw-result { border-top: 1px solid var(--border); }.proc-report .raw-result { overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--bg-card); }.proc-report details pre { max-height: 420px; }.anchor-details summary,.raw-result summary { padding: 13px 18px !important; } -.oracle-delivery { padding: 22px 18px; }.oracle-delivery span,.oracle-delivery strong,.oracle-delivery small { display: block; }.oracle-delivery span { color: var(--text-muted); font: 700 .62rem var(--font-scp, monospace); text-transform: uppercase; }.oracle-delivery strong { margin-top: 8px; color: var(--text-primary); font-size: 2rem; letter-spacing: -.04em; }.oracle-delivery small { margin-top: 8px; overflow-wrap: anywhere; color: var(--text-muted); font-size: .66rem; } -.preview-proc-result { border-color: var(--accent-border); box-shadow: 0 16px 50px rgba(105,62,255,.08); }.proc-preview { background: var(--bg-card); }.proc-preview-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 22px; border-bottom: 1px solid var(--border); background: var(--accent-soft); }.proc-preview-head > div > span { display: block; color: var(--accent-strong); font: 800 .61rem var(--font-scp, monospace); letter-spacing: .1em; }.proc-preview-head h2 { margin-top: 5px; color: var(--text-primary); font-size: 1.05rem; }.proc-preview-state { display: grid; grid-template-columns: repeat(2,1fr); border-bottom: 1px solid var(--border); }.proc-preview-state > div { padding: 16px 22px; }.proc-preview-state > div + div { border-left: 1px solid var(--border); }.proc-preview-state strong,.proc-preview-state small { display: block; }.proc-preview-state strong { color: var(--text-primary); font-size: .76rem; }.proc-preview-state small { margin-top: 5px; color: var(--text-muted); font-size: .68rem; line-height: 1.5; }.preview-delivery { border-bottom: 1px solid var(--border); }.preview-fields { display: grid; grid-template-columns: repeat(4,1fr); border-bottom: 1px solid var(--border); }.preview-fields > div { min-width: 0; padding: 16px 18px; border-right: 1px solid var(--border); }.preview-fields > div:nth-child(4n) { border-right: 0; }.preview-fields span,.preview-fields strong { display: block; overflow-wrap: anywhere; }.preview-fields span { color: var(--text-muted); font-size: .6rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }.preview-fields strong { margin-top: 6px; color: var(--text-primary); font-size: .75rem; }.proc-preview-warning { display: flex; align-items: center; gap: 14px; padding: 14px 22px; color: var(--text-secondary); background: rgba(180,95,0,.07); border-bottom: 1px solid var(--border); }.proc-preview-warning strong { flex: none; color: #9a5300; font-size: .7rem; }.proc-preview-warning span { font-size: .68rem; line-height: 1.45; }.preview-anchor-list > div { display: grid; grid-template-columns: 130px minmax(0,1fr); gap: 12px; padding: 10px 18px; border-top: 1px solid var(--border); }.preview-anchor-list span { color: var(--text-muted); font-size: .64rem; text-transform: capitalize; }.preview-anchor-list code { overflow: hidden; color: var(--text-secondary); font-size: .64rem; text-overflow: ellipsis; } - -.try-agents { margin-top: 82px; }.try-section-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 40px; margin-bottom: 25px; }.try-section-head span { color: var(--accent-strong); font: 700 .63rem var(--font-scp, monospace); letter-spacing: .12em; }.try-section-head h2 { font-size: 1.8rem; letter-spacing: -.025em; margin-top: 5px; }.try-section-head > p { max-width: 420px; color: var(--text-muted); font-size: .8rem; } -.try-agent-grid { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }.try-agent-grid button { min-height: 190px; padding: 20px; text-align: left; color: var(--text-primary); background: var(--bg-card); border: 0; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); cursor: pointer; font-family: inherit; }.try-agent-grid button:nth-child(3n){border-right:0}.try-agent-grid button:nth-last-child(-n+3){border-bottom:0}.try-agent-grid button:hover { background: var(--accent-soft); }.try-agent-grid button > span { color: var(--text-muted); font: .65rem var(--font-scp, monospace); }.try-agent-grid strong { display: block; font-size: .9rem; margin-top: 13px; }.try-agent-grid p { color: var(--text-muted); font-size: .72rem; line-height: 1.5; margin: 7px 0 20px; }.try-agent-grid i { color: var(--accent-strong); font-size: .7rem; font-style: normal; font-weight: 700; } - -@media (max-width: 900px) { - .try-hero { grid-template-columns: 1fr; min-height: 0; gap: 28px; }.try-hero h1 { font-size: 14vw; }.try-hero > p { max-width: 620px; } - .try-shell { grid-template-columns: 1fr; }.journey { border-left: 0; border-top: 1px solid var(--border); min-height: 0; }.journey-note { display: none; } - .picker-grid { grid-template-columns: repeat(2, 1fr); } - .proc-summary,.verify-grid { grid-template-columns: repeat(2,1fr); }.verify-grid > div:nth-child(2) { border-right: 0; }.verify-grid > div:nth-child(-n+2) { border-bottom: 1px solid var(--border); } - .try-agent-grid { grid-template-columns: repeat(2,1fr); }.try-agent-grid button:nth-child(3n){border-right:1px solid var(--border)}.try-agent-grid button:nth-child(2n){border-right:0}.try-agent-grid button:nth-last-child(-n+3){border-bottom:1px solid var(--border)}.try-agent-grid button:nth-last-child(-n+2){border-bottom:0} -} -@media (max-width: 580px) { - main { padding-left: 16px; padding-right: 16px; }.nav-inner { padding-left: 16px; padding-right: 16px; }.try-hero { padding-top: 20px; }.try-hero h1 { font-size: 16vw; } - .chat-body { padding: 20px 15px; }.bubble { max-width: 92%; }.ask-box,.job-box,.agent-picker { padding: 18px 15px; }.picker-grid { grid-template-columns: 1fr; }.job-head,.job-actions,.try-section-head,.rail-picker-head,.rail-disclosure,.rail-governance { align-items: stretch; flex-direction: column; }.rail-options { grid-template-columns: 1fr; }.job-actions .try-primary { width: 100%; }.try-agent-grid { grid-template-columns: 1fr; }.try-agent-grid button { border-right: 0 !important; border-bottom: 1px solid var(--border) !important; }.try-agent-grid button:last-child { border-bottom: 0 !important; } - .live-flow { padding: 18px 15px; }.live-flow-head,.proc-panel-head,.tx-link,.result-title,.proc-preview-head,.proc-preview-warning { align-items: stretch; flex-direction: column; }.proc-report { padding: 12px; gap: 12px; }.proc-summary,.proc-preview-state,.preview-fields { grid-template-columns: 1fr; }.proc-preview-state > div + div { border-left: 0; border-top: 1px solid var(--border); }.preview-fields > div { border-right: 0; border-bottom: 1px solid var(--border); }.candidate-head { display: none; }.candidate-row { grid-template-columns: 1fr 1fr; }.candidate-row > span:last-child { grid-column: 1 / -1; }.verify-grid { grid-template-columns: 1fr; }.verify-grid > div { border-right: 0; border-bottom: 1px solid var(--border); }.verify-grid > div:last-child { border-bottom: 0; }.finding-list article { grid-template-columns: 1fr; }.receipt-row { grid-template-columns: 26px minmax(0,1fr); }.receipt-row > a,.receipt-row > .muted-text { grid-column: 2; }.party-row { align-items: flex-start; flex-direction: column; }.party-row i { transform: rotate(90deg); }.preview-anchor-list > div { grid-template-columns: 1fr; } -} - -/* ── /try agent input forms ────────────────────────────────────────────── */ -.agent-form { display: flex; flex-direction: column; gap: 14px; } -.agent-form-note { margin: 0; padding: 10px 12px; border: 1px solid var(--border); border-left: 3px solid var(--accent-strong); border-radius: 8px; font-size: 0.8rem; color: var(--text-secondary); background: var(--surface-2); } -.field-stack { display: flex; flex-direction: column; gap: 12px; } -.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.field-grid.three { grid-template-columns: 1fr 1fr 1fr; } -@media (max-width: 640px) { .field-grid, .field-grid.three { grid-template-columns: 1fr; } } -.try-field label { display: flex; align-items: center; gap: 4px; } -.required-mark { color: var(--accent-strong); font-weight: 700; } -.field-error { display: block; margin-top: 4px; font-size: 0.75rem; color: var(--red-strong, #c0392b); } -.field-error.gateway { font-weight: 600; } -.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; cursor: pointer; } -.checkbox-row input { width: auto; } -.repeat-list { display: flex; flex-direction: column; gap: 10px; } -.repeat-row { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 10px; } -.repeat-row-head { display: flex; align-items: center; justify-content: space-between; font-size: 0.8rem; } -.add-row { align-self: flex-start; } -.kv-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: center; } -.kv-row.alias-row { grid-template-columns: 1fr auto; } -.advanced-json { border-top: 1px dashed var(--border); padding-top: 10px; } -.advanced-json summary { cursor: pointer; font-size: 0.8rem; color: var(--text-secondary); } -.advanced-json textarea { width: 100%; margin-top: 8px; } -.submitted-summary { margin-top: 10px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.75rem; } -.submitted-summary span { display: block; letter-spacing: 0.08em; color: var(--text-secondary); margin-bottom: 4px; } -.submitted-summary ul { margin: 0; padding-left: 16px; } - -/* ═══ /try-chat — recorded two-agent conversation (scoped .tc-*) ═══════════ */ -.tc-page { max-width: 940px; margin: 0 auto; padding: 32px 20px 80px; } -.tc-hero { text-align: center; padding: 8px 0 26px; } -.tc-kicker { display: inline-flex; align-items: center; gap: 7px; color: var(--accent-strong); font: 700 .64rem var(--font-scp, monospace); letter-spacing: .14em; } -.tc-kicker i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } -.tc-hero h1 { margin: 14px 0 12px; font-size: clamp(1.9rem, 4vw, 2.9rem); line-height: 1.05; letter-spacing: -0.02em; } -.tc-hero h1 em { font-style: italic; color: var(--accent-strong); } -.tc-hero p { max-width: 640px; margin: 0 auto; color: var(--text-secondary); font-size: 1.02rem; line-height: 1.6; } -.tc-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: center; margin-top: 22px; } -.tc-btn { display: inline-flex; align-items: center; gap: 7px; border-radius: 10px; padding: 11px 18px; font: 600 .9rem inherit; cursor: pointer; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); transition: .16s; } -.tc-btn small { color: var(--text-muted); font-weight: 500; font-size: .74rem; } -.tc-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } -.tc-btn-primary:hover { background: var(--accent-hover); } -.tc-btn-ghost:hover { border-color: var(--border-hover); } -.tc-btn:disabled { opacity: .55; cursor: default; } -.tc-tech-toggle { display: inline-flex; align-items: center; gap: 7px; color: var(--text-muted); font-size: .8rem; cursor: pointer; } -.tc-replay-note { display: grid; gap: 4px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid var(--accent-border); border-radius: 12px; background: var(--accent-soft); } -.tc-replay-note strong { color: var(--accent-strong); font-size: .78rem; } -.tc-replay-note span { color: var(--text-secondary); font-size: .76rem; line-height: 1.5; } -.tc-replay-note a { color: var(--accent-strong); font-weight: 700; } - -.tc-stagebar { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 8px 0 18px; } -.tc-stage { display: flex; gap: 9px; padding: 12px 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); opacity: .55; transition: .25s; } -.tc-stage-active, .tc-stage-done { opacity: 1; } -.tc-stage-active { border-color: var(--accent-border); background: var(--accent-soft); } -.tc-stage-done { border-color: var(--green-border); } -.tc-stage-num { flex: none; width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-hover); font: 700 .72rem var(--font-scp, monospace); } -.tc-stage-active .tc-stage-num { border-color: var(--accent); color: var(--accent-strong); } -.tc-stage-done .tc-stage-num { border-color: var(--green-strong); background: var(--green-soft); color: var(--green-strong); } -.tc-stage strong { display: block; font-size: .8rem; } -.tc-stage small { color: var(--accent-strong); font: 700 .56rem var(--font-scp, monospace); } -.tc-stage p { margin-top: 3px; color: var(--text-muted); font-size: .66rem; line-height: 1.4; } - -.tc-stage-legend { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; padding: 4px 0 16px; color: var(--text-secondary); font-size: .76rem; } -.tc-stage-legend span { display: inline-flex; align-items: center; gap: 6px; } -.tc-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } -.tc-dot-butler { background: var(--accent); } -.tc-dot-seller { background: #ea580c; } -.tc-dot-chain { background: #0891b2; } -.tc-dot-referee { background: #64748b; } - -.tc-transcript { min-height: 320px; max-height: 62vh; overflow-y: auto; padding: 20px 18px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-tinted); display: flex; flex-direction: column; gap: 12px; } -.tc-empty { margin: auto; text-align: center; color: var(--text-secondary); max-width: 420px; } -.tc-empty small { display: block; margin-top: 8px; color: var(--text-muted); font-size: .76rem; } - -.tc-stage-divider { display: flex; align-items: center; gap: 9px; margin: 12px 4px 4px; color: var(--text-muted); } -.tc-stage-divider::before, .tc-stage-divider::after { content: ""; height: 1px; flex: 1; background: var(--border); } -.tc-stage-divider span { font: 700 .58rem var(--font-scp, monospace); color: var(--accent-strong); background: var(--accent-soft); border: 1px solid var(--accent-border); border-radius: 999px; padding: 1px 8px; } -.tc-stage-divider b { font-size: .74rem; color: var(--text-secondary); } -.tc-stage-divider i { font-style: normal; font-size: .66rem; } - -.tc-turn { display: flex; gap: 10px; max-width: 78%; animation: tc-in .28s ease; } -@keyframes tc-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } -.tc-turn-left { align-self: flex-start; } -.tc-turn-right { align-self: flex-end; flex-direction: row-reverse; } -.tc-turn-center { align-self: center; max-width: 88%; } -.tc-avatar { flex: none; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font: 700 .82rem var(--font-scp, monospace); color: #fff; margin-top: 2px; } -.tc-turn-butler .tc-avatar { background: var(--accent); } -.tc-turn-seller .tc-avatar { background: #ea580c; } -.tc-bubble { padding: 11px 14px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card); } -.tc-turn-left .tc-bubble { border-top-left-radius: 4px; } -.tc-turn-right .tc-bubble { border-top-right-radius: 4px; background: color-mix(in srgb, #ea580c 6%, var(--bg-card)); border-color: color-mix(in srgb, #ea580c 22%, var(--border)); } -.tc-turn-center .tc-bubble { text-align: center; background: var(--bg-subtle); border-style: dashed; } -.tc-turn-referee .tc-bubble { border-color: var(--border-hover); } -.tc-who { display: block; font-size: .72rem; font-weight: 700; color: var(--text-secondary); margin-bottom: 3px; } -.tc-who small { font-weight: 500; color: var(--text-muted); } -.tc-bubble p { font-size: .9rem; line-height: 1.5; color: var(--text-primary); } -.tc-turn-center .tc-bubble p { font-size: .82rem; color: var(--text-secondary); } - -.tc-receipt { --receipt-ink: #0e7490; display: inline-flex; align-items: center; gap: 8px; margin-top: 8px; padding: 5px 9px; border-radius: 8px; background: color-mix(in srgb, var(--receipt-ink) 9%, transparent); border: 1px solid color-mix(in srgb, var(--receipt-ink) 28%, transparent); } -[data-theme="dark"] .tc-receipt { --receipt-ink: #67e8f9; } -.tc-receipt-tag { font: 700 .56rem var(--font-scp, monospace); letter-spacing: .05em; color: var(--receipt-ink); } -.tc-receipt a { font: 600 .64rem var(--font-scp, monospace); color: var(--receipt-ink); } -.tc-receipt code { font: 600 .64rem var(--font-scp, monospace); color: var(--text-muted); } -.tc-raw { margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--border); } -.tc-raw span { display: block; font: 700 .52rem var(--font-scp, monospace); letter-spacing: .06em; color: var(--text-muted); text-transform: uppercase; } -.tc-raw code { font: .64rem var(--font-scp, monospace); color: var(--text-secondary); overflow-wrap: anywhere; } - -.tc-typing { align-self: flex-start; display: inline-flex; gap: 4px; padding: 10px 14px; } -.tc-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); animation: tc-blink 1.2s infinite; } -.tc-typing span:nth-child(2) { animation-delay: .2s; } -.tc-typing span:nth-child(3) { animation-delay: .4s; } -@keyframes tc-blink { 0%, 60%, 100% { opacity: .3; } 30% { opacity: 1; } } - -.tc-outcome { margin-top: 20px; padding: 22px; border: 1px solid var(--green-border); border-radius: 16px; background: color-mix(in srgb, var(--green-soft) 40%, var(--bg-card)); text-align: center; } -.tc-outcome-badge { display: inline-block; font: 700 .74rem var(--font-scp, monospace); color: var(--green-strong); border: 1px solid var(--green-border); border-radius: 999px; padding: 4px 12px; margin-bottom: 12px; } -.tc-outcome p { max-width: 620px; margin: 0 auto; color: var(--text-secondary); font-size: .95rem; line-height: 1.6; } -.tc-outcome-actions { display: flex; gap: 10px; justify-content: center; margin-top: 16px; } - -@media (max-width: 720px) { - .tc-stagebar { grid-template-columns: 1fr 1fr; } - .tc-stage p { display: none; } - .tc-turn { max-width: 92%; } -} - -/* ═══ Homepage — recorded deal landing page (scoped .hp-*, reuses .tc-* bubbles) ═ */ -.hp-page { display: flex; flex-direction: column; gap: 72px; padding-bottom: 40px; } -.hp-hero { - display: grid; - grid-template: - "copy demo" auto - "actions demo" auto - / minmax(0, 1.05fr) minmax(0, 1fr); - column-gap: 44px; - align-content: center; - padding-top: 16px; -} -.hp-hero-copy { grid-area: copy; align-self: end; } -.hp-hero-actions { grid-area: actions; align-self: start; } -.hp-hero-copy h1 { margin: 14px 0 14px; font-size: clamp(2rem, 4.4vw, 3.1rem); line-height: 1.06; letter-spacing: -0.02em; } -.hp-hero-copy h1 em { font-style: italic; color: var(--accent-strong); } -.hp-hero-copy p { color: var(--text-secondary); font-size: 1rem; line-height: 1.65; max-width: 520px; } -.hp-cta-row { display: flex; align-items: center; gap: 18px; margin-top: 22px; flex-wrap: wrap; } -.hp-cta-center { justify-content: center; } -.hp-cta-ghost { color: var(--accent-strong); font-weight: 600; font-size: .92rem; } -.hp-stats { display: flex; gap: 28px; margin-top: 28px; flex-wrap: wrap; } -.hp-stats div { display: flex; flex-direction: column; } -.hp-stats strong { font-size: 1.35rem; letter-spacing: -0.01em; } -.hp-stats span { color: var(--text-muted); font-size: .72rem; } - -.hp-demo { grid-area: demo; align-self: center; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); overflow: hidden; } -.hp-demo-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border); } -.hp-live { display: inline-flex; align-items: center; gap: 7px; color: var(--text-muted); font: 600 .66rem var(--font-scp, monospace); letter-spacing: .04em; } -.hp-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); animation: livePulse 1.6s infinite; } -.hp-demo-actions { display: flex; align-items: center; gap: 10px; } -.hp-demo-stages { display: flex; gap: 6px; } -.hp-demo-stages span { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; border: 2px solid var(--border-hover); color: var(--text-muted); font: 700 .62rem var(--font-scp, monospace); transition: .25s; } -.hp-demo-stages span.active { border-color: var(--accent); color: var(--accent-strong); } -.hp-demo-stages span.done { border-color: var(--green-strong); background: var(--green-soft); color: var(--green-strong); } -.hp-demo-control { border: 1px solid var(--border); border-radius: var(--radius-chip); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; padding: 4px 8px; font: 600 .62rem var(--font-scp, monospace); } -.hp-demo-control:hover { border-color: var(--border-hover); color: var(--text-primary); } -.hp-demo-scroll { height: 380px; overflow-y: auto; padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; background: var(--bg-tinted); } -.hp-turn .tc-bubble p { font-size: .8rem; } -.hp-turn .tc-who { font-size: .64rem; } -.hp-turn .tc-avatar { width: 26px; height: 26px; font-size: .7rem; } -.hp-typing { padding: 6px 10px; } -.hp-settled { align-self: center; margin-top: 6px; color: var(--green-strong); font: 600 .68rem var(--font-scp, monospace); } -.hp-demo-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-top: 1px solid var(--border); font-size: .72rem; color: var(--text-muted); flex-wrap: wrap; } -.hp-demo-foot em { font-style: normal; color: var(--text-secondary); } -.hp-demo-foot a { color: var(--accent-strong); font-weight: 600; } - -.hp-section-head { max-width: 640px; margin-bottom: 24px; } -.hp-section-head h2 { margin-top: 10px; font-size: clamp(1.4rem, 2.6vw, 1.9rem); letter-spacing: -0.015em; } -.hp-section-head p { margin-top: 10px; color: var(--text-secondary); font-size: .92rem; line-height: 1.6; } - -.hp-deal-list { border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); overflow: hidden; } -.hp-deal-row { display: grid; grid-template-columns: 200px 1fr 260px; gap: 20px; align-items: baseline; padding: 16px 20px; } -.hp-deal-row + .hp-deal-row { border-top: 1px solid var(--border); } -.hp-deal-name { display: flex; flex-direction: column; gap: 5px; } -.hp-deal-name h3 { font-size: .92rem; } -.hp-deal-row p { color: var(--text-secondary); font-size: .82rem; line-height: 1.55; } -.hp-deal-row > code { color: var(--text-muted); font: .66rem var(--font-scp, monospace); overflow-wrap: anywhere; justify-self: end; text-align: right; } -.hp-deal-status { align-self: flex-start; font: 600 .62rem var(--font-scp, monospace); } -.hp-deal-status-recorded { color: var(--green-strong); } -.hp-deal-status-live { color: var(--accent-strong); } -.hp-deal-status-spec { color: var(--text-muted); } - -.hp-why-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); overflow: hidden; } -.hp-why { padding: 18px 16px; } -.hp-why + .hp-why { border-left: 1px solid var(--border); } -.hp-why .mono { font: 600 .62rem var(--font-scp, monospace); color: var(--accent-strong); } -.hp-why strong { display: block; margin-top: 6px; font-size: .92rem; } -.hp-why p { margin-top: 6px; color: var(--text-secondary); font-size: .76rem; line-height: 1.55; } -.hp-receipts-note { max-width: 640px; margin-top: 14px; color: var(--text-muted); font-size: .8rem; line-height: 1.6; } -.hp-receipts-note a { color: var(--accent-strong); font-weight: 600; } - -.hp-closing { padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-tinted); } -.hp-closing p { margin: 8px 0 16px; max-width: 560px; color: var(--text-secondary); font-size: .875rem; } - -.hp-stats-empty { margin-top: 28px; color: var(--text-muted); font: 600 .72rem var(--font-scp, monospace); } - -@media (max-width: 900px) { - .hp-hero { - grid-template: - "copy" - "demo" - "actions" - / minmax(0, 1fr); - row-gap: 28px; - } - .hp-hero-copy, .hp-demo, .hp-hero-actions { align-self: stretch; } - .hp-hero-actions .hp-cta-row { margin-top: 0; } - .hp-deal-row { grid-template-columns: 1fr; gap: 8px; } - .hp-deal-row > code { justify-self: start; text-align: left; } - .hp-why-grid { grid-template-columns: 1fr; } - .hp-why + .hp-why { border-left: 0; border-top: 1px solid var(--border); } - .hp-demo-scroll { height: 320px; } -} - @media (max-width: 1120px) { .nav-menu-button { display: grid; place-items: center; } .nav .nav-links { diff --git a/reference-implementations/dacs-directory/app/page.tsx b/reference-implementations/dacs-directory/app/page.tsx index be9c3d8..8d9a807 100644 --- a/reference-implementations/dacs-directory/app/page.tsx +++ b/reference-implementations/dacs-directory/app/page.tsx @@ -1,6 +1,5 @@ import Link from "next/link"; import type { Metadata } from "next"; -import HomeDealDemo from "@/src/components/HomeDealDemo"; import { homeCatalogDisplayState } from "@/src/components/home-hero-state"; import { loadCatalog } from "@/src/catalog/store"; import { activeCatalogListings, activeCatalogSellers } from "@/src/catalog/discovery"; @@ -8,39 +7,29 @@ import { activeCatalogListings, activeCatalogSellers } from "@/src/catalog/disco export const dynamic = "force-dynamic"; export const metadata: Metadata = { - title: { absolute: "DACS Directory · Verifiable agent commerce" }, - description: "Watch a real agent-to-agent purchase with verifiable receipts, then discover the agents that trade this way.", + title: { absolute: "DACS Directory · Verifiable agent discovery" }, + description: "Discover independently indexed agent services and inspect their signed listings, identities, and deal evidence.", alternates: { canonical: "/" }, }; -const DEAL_TYPES = [ +const DIRECTORY_CAPABILITIES = [ { - name: "Fixed price", - line: "A posted price — 0.01 DEM per call.", - spec: "pricing: fixed · negotiate-fixed-price", - status: "live", - tone: "live" as const, + title: "Current signed listings", + body: "The indexer reads chain state, verifies listing signatures and content hashes, and excludes validly revoked versions from active discovery.", + href: "/discover", + action: "Browse active services", }, { - name: "Metered", - line: "Per-unit pricing; the total locks at commit.", - spec: "pricing: metered · MTR-1..5, DACS-4 v0.3", - status: "spec v0.3", - tone: "spec" as const, + title: "Inspectable evidence", + body: "Deal pages expose the referenced artifacts and verification result instead of turning an advisory reputation hint into a trust claim.", + href: "/verify", + action: "Verify a deal", }, { - name: "RFQ", - line: "Quote and counter inside a signed band. The deal above is an RFQ.", - spec: "pricing: negotiable · negotiate-rfq", - status: "recorded above", - tone: "recorded" as const, - }, - { - name: "Sealed bid", - line: "Hidden bids, revealed together; a published rule picks the winner.", - spec: "pricing: auction · negotiate-sealed-envelope", - status: "spec v0.3", - tone: "spec" as const, + title: "Machine-readable catalog", + body: "Agents can discover the same filtered catalog through the linked API, manifest, schema, and canonical service records.", + href: "/api/dacs", + action: "Open the developer API", }, ]; @@ -55,87 +44,75 @@ export default function Home() { const indexedAgo = indexedAgoMin < 60 ? `${indexedAgoMin}m` : `${Math.round(indexedAgoMin / 60)}h`; return ( -
-
-
-
verifiable agent commerce
-

This is a real deal between two agents.

-

- A buyer agent purchases a code audit from a seller agent — price agreed, DEM paid, work - delivered, five receipts on the Demos chain. This directory indexes the agents that trade this way. -

+ <> +
+
chain-indexed service discovery
+

Find agents you can verify.

+

+ The Community Directory indexes DACS service listings from chain state, verifies the + artifacts it can prove, and exposes the same catalog to people and software. +

+
+ Browse the directory + List your service
- -
-
- Browse the directory - Run a deal yourself → +
+
+ {listings.length} + active services +
+
+ {sellers.length} + indexed sellers +
+
+ {verifiedDeals} + verified deal graphs +
+
+ {indexed ? indexedAgo : "pending"} + + {catalogDisplayState === "indexing" + ? "initial chain index" + : catalogDisplayState === "empty" + ? "last index; no active listings" + : "since last index"} +
- {catalogDisplayState === "summary" ? ( -
-
{listings.length}active services
-
{sellers.length}indexed agents
-
{verifiedDeals}verified deals
-
{indexedAgo}since last index
-
- ) : ( -

- {catalogDisplayState === "indexing" ? "indexing the chain…" : "no active services indexed yet"} -

- )}
-
-
-
four ways to price a deal
-

From a posted price to a sealed auction

-

Every route ends in the same five receipts.

+
+
+
+
directory boundary
+

Discovery backed by evidence

+
+ How verification works →
-
- {DEAL_TYPES.map((deal) => ( -
-

{deal.name}

{deal.status}
-

{deal.line}

- {deal.spec} -
+
+ {DIRECTORY_CAPABILITIES.map((capability) => ( +
+

{capability.title}

+

{capability.body}

+ {capability.action} → +
))}
-
-
-
one deal · five receipts
-

Why five stages

-
-
- {[ - { n: 1, name: "Identify", why: "One primary identity with explicitly linked wallets and Web2 accounts." }, - { n: 2, name: "Vet", why: "Credentials, sanctions screens and reputation, checked before committing." }, - { n: 3, name: "Negotiate", why: "Off-chain conversation, on-chain commitments. Terms anchor at commit." }, - { n: 4, name: "Settle", why: "Value moves on the agreed rail; both sides clear in the same window." }, - { n: 5, name: "Verify", why: "A tamper-proof attestation closes the loop. Auditable forever after." }, - ].map((stage) => ( -
- DACS-{stage.n} - {stage.name} -

{stage.why}

-
- ))} -
-

- Each stage anchors its receipt before the next begins. How it works → +

+
publish once · discover openly
+

Run an agent? Make its service discoverable.

+

+ Publish a signed listing on Demos and submit its bounded discovery coordinates. Registration + helps the catalog find it; the Directory still verifies the chain artifact independently.

-
- -
-

Run an agent? Get listed.

-

Publish a signed listing on-chain; the catalog verifies it and indexes every deal you complete.

-
- Register an agent - Verify a deal yourself → +
+ Register a service + Read the machine manifest
-
+ ); } diff --git a/reference-implementations/dacs-directory/app/sitemap.ts b/reference-implementations/dacs-directory/app/sitemap.ts index 80fb742..93ba8fa 100644 --- a/reference-implementations/dacs-directory/app/sitemap.ts +++ b/reference-implementations/dacs-directory/app/sitemap.ts @@ -11,8 +11,6 @@ export default function sitemap(): MetadataRoute.Sitemap { const staticRoutes: MetadataRoute.Sitemap = [ { url: base, lastModified: modified, changeFrequency: "hourly", priority: 1 }, { url: `${base}/discover`, lastModified: modified, changeFrequency: "hourly", priority: 0.9 }, - { url: `${base}/try`, changeFrequency: "weekly", priority: 0.9 }, - { url: `${base}/try-chat`, changeFrequency: "monthly", priority: 0.7 }, { url: `${base}/how-it-works`, changeFrequency: "monthly", priority: 0.7 }, { url: `${base}/verify`, changeFrequency: "monthly", priority: 0.6 }, { url: `${base}/register`, changeFrequency: "monthly", priority: 0.6 }, diff --git a/reference-implementations/dacs-directory/app/try-chat/page.tsx b/reference-implementations/dacs-directory/app/try-chat/page.tsx deleted file mode 100644 index 4b63c87..0000000 --- a/reference-implementations/dacs-directory/app/try-chat/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import TryChat from "@/src/components/TryChat"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Watch a recorded DACS deal", - description: "Replay a completed deal between a buyer's Butler and a seller's Auditor, with genuine on-chain receipts and no new payment.", - alternates: { canonical: "/try-chat" }, -}; - -// Zero-cost explainer. All live procurement remains on /try, which owns the -// idempotency, recovery, payment-rail selection, and evidence verification. -export default function TryChatPage() { - return ; -} diff --git a/reference-implementations/dacs-directory/app/try/page.tsx b/reference-implementations/dacs-directory/app/try/page.tsx deleted file mode 100644 index 8194fb1..0000000 --- a/reference-implementations/dacs-directory/app/try/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import TryDacs from "@/src/components/TryDacs"; -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Try DACS", - description: "Run a live fixed-price or RFQ agent procurement, pay with DEM or Base Sepolia USDC through x402, and inspect every DACS receipt.", - alternates: { canonical: "/try" }, -}; - -export default function TryPage() { - return ; -} diff --git a/reference-implementations/dacs-directory/e2e/README.md b/reference-implementations/dacs-directory/e2e/README.md index e0dca01..be70db5 100644 --- a/reference-implementations/dacs-directory/e2e/README.md +++ b/reference-implementations/dacs-directory/e2e/README.md @@ -1,34 +1,11 @@ -# `/try` browser tests +# DACS Directory browser tests -The Playwright suite has two layers: +The Playwright suite covers the Directory's public discovery and seller-publication +surfaces without contacting a live chain or spending funds. -- `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:live` contains five serial checks against the live Butler gateway. It is skipped unless the operator explicitly authorizes a capped testnet purchase. +- `npm run test:e2e` runs the deterministic landing-page, discovery, navigation, and + registration regressions used in CI. +- `npm run test:e2e:ui` opens Playwright's interactive runner for local debugging. -## Install the browser - -```bash -npx playwright install chromium -``` - -## Run the zero-cost suite - -```bash -npm run test:e2e -``` - -## Run the live suite - -The live suite makes exactly one new procurement purchase. The remaining four checks inspect that job or prove that a second POST is not sent. It refuses to run with a budget cap above 5 DEM. - -```bash -RUN_LIVE_PAID_E2E=1 LIVE_E2E_MAX_DEM=5 npm run test:e2e:live -``` - -Optional overrides: - -- `LIVE_BUTLER_ORIGIN` changes the gateway origin. -- Mock artifacts are written under `test-results/playwright/` and `playwright-report/`. -- Live artifacts are isolated under `test-results/playwright-live/` and `playwright-report-live/`, so a routine mocked run cannot overwrite payment evidence. - -Treat the live command as a payment authorization. Do not add `RUN_LIVE_PAID_E2E=1` to normal CI secrets or repository configuration. +The configured development server starts on `http://localhost:3400`; the readiness +check uses `/`, and `NEXT_PUBLIC_DIRECTORY_URL` is set to the same local origin. diff --git a/reference-implementations/dacs-directory/e2e/home.spec.ts b/reference-implementations/dacs-directory/e2e/home.spec.ts index b3502c2..9c69676 100644 --- a/reference-implementations/dacs-directory/e2e/home.spec.ts +++ b/reference-implementations/dacs-directory/e2e/home.spec.ts @@ -1,24 +1,19 @@ import { expect, test } from "@playwright/test"; -test("the landing page leads to discovery and exposes playback controls", async ({ page }) => { +test("the landing page is catalog-led", async ({ page }) => { await page.goto("/"); - await expect(page.getByRole("heading", { name: "This is a real deal between two agents." })).toBeVisible(); - await expect(page.getByRole("link", { name: "Browse the directory" })).toHaveAttribute("href", "/discover"); - - const playback = page.getByRole("button", { name: "Pause" }); - await expect(playback).toBeVisible(); - await playback.click(); - await expect(page.getByRole("button", { name: "Play" })).toHaveAttribute("aria-pressed", "true"); - - await page.goto("/discover"); await expect(page.getByRole("heading", { name: "Find agents you can verify." })).toBeVisible(); + await expect(page.getByRole("link", { name: "Browse the directory" })).toHaveAttribute("href", "/discover"); + await expect(page.getByRole("link", { name: "List your service", exact: true })).toHaveAttribute("href", "/register"); + await expect(page.getByLabel("Catalog summary")).toBeVisible(); + await expect(page.getByText("initial chain index")).toBeVisible(); }); test("the proposal URL redirects to the landing page", async ({ page }) => { await page.goto("/home-proposal"); await expect(page).toHaveURL(/\/$/); - await expect(page.getByRole("heading", { name: "This is a real deal between two agents." })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Find agents you can verify." })).toBeVisible(); }); test("the primary navigation collapses before it can overflow", async ({ page }) => { @@ -29,44 +24,18 @@ test("the primary navigation collapses before it can overflow", async ({ page }) await expect(menu).toBeVisible(); await menu.click(); await expect(page.getByRole("link", { name: "discover", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "list your service", exact: true })).toBeVisible(); }); -test("the mobile hero keeps its visual and keyboard order aligned", async ({ page }) => { +test("the mobile landing page keeps primary actions and catalog state visible", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/"); - const heroBlocks = page.locator(".hp-hero-copy, .hp-demo, .hp-hero-actions"); - await expect(heroBlocks).toHaveCount(3); - const blockTops = await heroBlocks.evaluateAll((blocks) => - blocks.map((block) => Math.round(block.getBoundingClientRect().top)), - ); - assertNondecreasing(blockTops); - - const emptyState = page.locator(".hp-stats-empty"); - await expect(emptyState).toHaveText("indexing the chain…"); - const emptyStateFontSize = await emptyState.evaluate((element) => - Number.parseFloat(getComputedStyle(element).fontSize), - ); - expect(emptyStateFontSize).toBeLessThan(13); - - const focusableLabels = await page.locator(".hp-hero a, .hp-hero button").evaluateAll((elements) => - elements.map((element) => element.textContent?.trim()), - ); - expect(focusableLabels).toEqual([ - "Pause", - "try dacs →", - "Browse the directory", - "Run a deal yourself →", - ]); + await expect(page.getByRole("heading", { name: "Find agents you can verify." })).toBeVisible(); + await expect(page.getByRole("link", { name: "Browse the directory" })).toBeVisible(); + await expect(page.getByRole("link", { name: "List your service", exact: true })).toBeVisible(); - const focusableTops = await page.locator(".hp-hero a, .hp-hero button").evaluateAll((elements) => - elements.map((element) => Math.round(element.getBoundingClientRect().top)), - ); - assertNondecreasing(focusableTops); + const summary = page.getByLabel("Catalog summary"); + await expect(summary).toBeVisible(); + await expect(summary.locator(":scope > div")).toHaveCount(4); }); - -function assertNondecreasing(values: number[]) { - for (let index = 1; index < values.length; index += 1) { - expect(values[index]).toBeGreaterThanOrEqual(values[index - 1]!); - } -} diff --git a/reference-implementations/dacs-directory/e2e/try-chat.spec.ts b/reference-implementations/dacs-directory/e2e/try-chat.spec.ts deleted file mode 100644 index 6467998..0000000 --- a/reference-implementations/dacs-directory/e2e/try-chat.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("the recorded deal is inspectable and cannot dispatch a purchase", async ({ page }) => { - let procurementRequests = 0; - await page.route("**/demo/procurement**", async (route) => { - procurementRequests += 1; - await route.abort("blockedbyclient"); - }); - - await page.goto("/try-chat"); - await expect(page.getByLabel("Recorded replay disclosure")).toContainText("never starts a job or spends funds"); - await expect(page.getByRole("link", { name: /Run a live deal/ })).toHaveAttribute("href", "/try"); - - await page.getByRole("button", { name: /Watch the recorded deal/ }).click(); - await page.getByRole("button", { name: "Show the full deal now" }).click(); - - await expect(page.locator(".tc-outcome-badge")).toContainText("Recorded deal settled & verified"); - await expect(page.locator(".tc-stage-done")).toHaveCount(5); - const payment = page.getByRole("link", { name: /verify tx 53dd8a7b…e0ff24/ }); - await expect(payment).toHaveAttribute("href", "https://explorer.demos.sh/transactions/53dd8a7b34f7d29377c27599e17a5742b2c7296dd048b1235c04359957e0ff24"); - expect(procurementRequests).toBe(0); -}); diff --git a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts b/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts deleted file mode 100644 index e80b787..0000000 --- a/reference-implementations/dacs-directory/e2e/try-dacs-fixtures.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { expect, type BrowserContext, type Page, type Route } from "@playwright/test"; - -export const PROCUREMENT_RUN_KEY = "dacs-try:procurement-run"; -export const PROCUREMENT_LOCK_NAME = "dacs-try:procurement-dispatch"; - -export const exampleInput = { - goal: "Audit the supplied source and return a content-bound security report.", - budgetDem: 5, - files: [{ path: "app.js", content: "export const greeting = 'hello';\n" }], -}; - -const x402Governance = { - status: "operator-provisional", - conformantAuthority: false, - signer: "did:demos:agent:mock-steward", - disclosure: "https://github.com/DACS-Agent-commerce/DACS-Standard/issues/274", -}; - -function railReadiness() { - return { - "pay-dem": { executable: true, reasons: [] }, - "pay-x402": { executable: true, reasons: [], railGovernance: x402Governance }, - }; -} - -const commonProfile = { - timing: { healthyMinSec: 10, healthyMaxSec: 30, hardTimeoutSec: 180, protocolFloorSec: 0 }, - confirmationGates: ["commit-agreement", "payment"], - paymentRails: ["pay-dem", "pay-x402"], - implementationStatus: "live", - executable: true, - reasons: [], -}; - -export const procurementOptions = { - profiles: [ - { - ...commonProfile, - id: "oracle-auto-accept", - title: "Buy an attested crypto price", - agentName: "Oracle Desk", - serviceId: "oracle-data", - mode: "fixed-price-auto-accept", - negotiationPhase: "negotiate-fixed-price", - summary: "Buy a posted-price public data point.", - fields: [], - sampleInput: { product: "crypto-price", params: { id: "bitcoin" }, paymentRail: "pay-dem" }, - railInputs: [ - { rail: "pay-dem", fields: [], sampleInput: { product: "crypto-price", params: { id: "bitcoin" }, paymentRail: "pay-dem" } }, - { rail: "pay-x402", fields: [], sampleInput: { product: "crypto-price", params: { id: "bitcoin" }, paymentRail: "pay-x402" } }, - ], - railReadiness: railReadiness(), - }, - { - ...commonProfile, - id: "dd-live-fixed", - title: "Commission a due-diligence report", - agentName: "Due Diligence Researcher", - serviceId: "due-diligence", - mode: "fixed-price-co-sign", - negotiationPhase: "negotiate-fixed-price", - summary: "Buy a jointly signed fixed-price research report.", - fields: [], - sampleInput: { kind: "npm-package", subject: "express", paymentRail: "pay-dem" }, - railInputs: [ - { rail: "pay-dem", fields: [], sampleInput: { kind: "npm-package", subject: "express", paymentRail: "pay-dem" } }, - { rail: "pay-x402", fields: [], sampleInput: { kind: "npm-package", subject: "express", paymentRail: "pay-x402" } }, - ], - railReadiness: railReadiness(), - }, - { - ...commonProfile, - id: "security-audit-rfq", - title: "Negotiate a bounded security audit", - agentName: "Security Auditor", - serviceId: "security-audit", - mode: "rfq", - negotiationPhase: "negotiate-rfq", - summary: "Run a live RFQ and buy a content-bound security report.", - fields: [], - sampleInput: { ...exampleInput, paymentRail: "pay-dem" }, - railInputs: [ - { rail: "pay-dem", fields: [], sampleInput: { ...exampleInput, paymentRail: "pay-dem" } }, - { - rail: "pay-x402", - fields: [], - sampleInput: { goal: exampleInput.goal, budgetUsdc: 0.1, files: exampleInput.files, paymentRail: "pay-x402" }, - }, - ], - railReadiness: railReadiness(), - }, - ], -}; - -const at = "2026-07-20T12:00:00.000Z"; - -export const acceptedResult = { - status: "settled-and-accepted", - decision: { - outcome: "selected", - winner: { provider: "Security Auditor", listingId: "audit-negotiator", price: 1 }, - candidates: [{ provider: "Security Auditor", listingId: "audit-negotiator", askPrice: 1, chosenRail: "pay-dem" }], - }, - negotiation: { - protocol: "l2ps", - terms: { tier: "bounded", deadline: "5 minutes", price: 1 }, - buyerSignature: { party: "buyer", algorithm: "ed25519", value: "buyer-signature" }, - sellerSignature: { party: "seller", algorithm: "ed25519", value: "seller-signature" }, - }, - settlement: { - amountDem: 1, - rail: "pay-dem", - payer: "did:demos:buyer", - payee: "did:demos:seller", - txHash: "mock-payment-transaction", - }, - delivery: { verified: true, report: { findings: [] } }, - evaluation: { - accepted: true, - rulingValid: true, - ruling: { verdict: "accept" }, - }, - bundleVerification: { ok: true }, - reconciliation: { reconciled: true }, - anchors: { listing: "mock-listing-anchor", agreement: "mock-agreement-anchor" }, - transactions: [ - { kind: "listing", name: "DACS-1 listing", address: "mock-listing-anchor", txRef: "mock-listing-tx" }, - { kind: "vet", name: "DACS-2 vet", address: "mock-vet-anchor", txRef: "mock-vet-tx" }, - { kind: "agreement", name: "DACS-3 agreement", address: "mock-agreement-anchor", txRef: "mock-agreement-tx" }, - { kind: "payment", name: "DEM payment", txRef: "mock-payment-transaction" }, - { kind: "bundle", name: "DACS-5 bundle", address: "mock-bundle-anchor", txRef: "mock-bundle-tx" }, - ], -}; - -export const completedJob = { - id: "job-e2e-1", - status: "complete", - phase: "complete", - preview: null, - events: [ - { phase: "discovering", label: "Signed listing verified", at, txRef: "mock-listing-tx" }, - { phase: "selecting", label: "Counterparty vet anchored", at, txRef: "mock-vet-tx" }, - { phase: "agreeing", label: "Dual-signed agreement anchored", at, txRef: "mock-agreement-tx" }, - { phase: "settling", label: "Payment evidence recorded", at, txRef: "mock-payment-transaction" }, - { phase: "complete", label: "Reconciled DACS-5 bundle anchored", at, txRef: "mock-bundle-tx" }, - ], - result: acceptedResult, -}; - -export const securityPreviewJob = { - id: completedJob.id, - status: "running", - phase: "verifying", - events: [ - ...completedJob.events.slice(0, 4), - { phase: "verifying", label: "Seller delivery and payment evidence verified", at, txRef: "mock-delivery-tx" }, - ], - preview: { - kind: "dacs-procurement-delivery-preview", - status: "report-verified-finalising-dacs5", - jobId: "web-auditor-e2e-1", - delivery: { - verified: true, - report: { - version: 1, - target: "(posted content)", - findings: [{ - id: "SEC-1", - severity: "high", - title: "Unsafe dynamic execution", - detail: "A dynamic code path requires review.", - file: "app.js", - line: 4, - }], - }, - }, - anchors: { - listing: "mock-listing-anchor", - agreement: "mock-agreement-anchor", - commitment: "mock-commitment-anchor", - paymentEvidence: "mock-payment-evidence-anchor", - delivery: "mock-delivery-anchor", - deliveryEvidence: "mock-delivery-evidence-anchor", - }, - }, -}; - -export const failedSecurityPreviewJob = { - ...securityPreviewJob, - status: "failed", - phase: "verifying", - error: "Buyer DACS-5 bundle anchoring failed after payment evidence was recorded.", -}; - -const x402PaymentTx = `0x${"ab".repeat(32)}`; - -export const x402CompletedJob = { - ...completedJob, - events: completedJob.events.map((event) => event.phase === "settling" - ? { ...event, txRef: x402PaymentTx } - : event), - result: { - ...acceptedResult, - decision: { - ...acceptedResult.decision, - winner: { ...acceptedResult.decision.winner, price: 0.05 }, - candidates: acceptedResult.decision.candidates.map((candidate) => ({ - ...candidate, - askPrice: 0.05, - chosenRail: "pay-x402", - })), - }, - negotiation: { - ...acceptedResult.negotiation, - terms: { ...acceptedResult.negotiation.terms, price: { amount: 0.05, currency: "USDC" } }, - }, - settlement: { - amount: { amount: 0.05, currency: "USDC" }, - rail: "pay-x402", - payer: "0x1111111111111111111111111111111111111111", - payee: "0x2222222222222222222222222222222222222222", - txHash: x402PaymentTx, - railGovernance: x402Governance, - }, - transactions: acceptedResult.transactions.map((transaction) => transaction.kind === "payment" - ? { ...transaction, name: "x402 USDC payment", txRef: x402PaymentTx } - : transaction), - }, -}; - -export type MockGatewayOptions = { - onProcurementPost?: (route: Route) => Promise | void; - onProcurementGet?: (route: Route) => Promise | void; -}; - -function json(route: Route, body: unknown, status = 200) { - return route.fulfill({ - status, - contentType: "application/json", - body: JSON.stringify(body), - headers: { "access-control-allow-origin": "*" }, - }); -} - -export async function installMockGateway(context: BrowserContext, options: MockGatewayOptions = {}) { - await context.route("**/api/dacs/listings?**", (route) => json(route, { listings: [] })); - // Register the wildcard first: Playwright evaluates matching routes in - // reverse registration order, so the explicit /options contract below wins. - await context.route("**/demo/procurement/*", async (route) => { - if (options.onProcurementGet) return options.onProcurementGet(route); - return json(route, completedJob); - }); - await context.route("**/demo/procurement", async (route) => { - if (route.request().method() === "OPTIONS") return json(route, {}); - if (options.onProcurementPost) return options.onProcurementPost(route); - return json(route, completedJob); - }); - await context.route("**/demo/procurement/options", (route) => json(route, procurementOptions)); -} - -export async function chooseProcurementExample(page: Page, rail: "pay-dem" | "pay-x402" = "pay-dem") { - await page.goto("/try"); - const agent = page.getByRole("button", { name: /Security Auditor/ }).first(); - await expect(agent).toBeVisible(); - await agent.click(); - const railName = rail === "pay-x402" ? /USDC · x402/ : /DEM · Demos/; - const railButton = page.getByRole("group", { name: "Payment rail" }).getByRole("button", { name: railName }); - await expect(railButton).toBeEnabled(); - await railButton.click(); - await page.getByRole("button", { name: "Load example" }).click(); - await expect(page.getByRole("button", { name: /Run the full deal/ })).toBeEnabled(); -} - -export async function expectAcceptedEvidence(page: Page) { - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible(); - await expect(page.getByText("Settled & accepted", { exact: true })).toBeVisible(); - await expect(page.getByText("broadcast & recorded", { exact: true })).toBeVisible(); - await expect(page.getByText("dual-signed", { exact: true })).toBeVisible(); - await expect(page.getByText("accepted", { exact: true }).last()).toBeVisible(); - await expect(page.locator(".journey-step.complete")).toHaveCount(5); - await expect(page.getByRole("link", { name: /View on explorer/ })).toBeVisible(); -} diff --git a/reference-implementations/dacs-directory/e2e/try-dacs.live.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs.live.spec.ts deleted file mode 100644 index 371215c..0000000 --- a/reference-implementations/dacs-directory/e2e/try-dacs.live.spec.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { expect, test, type BrowserContext, type Page, type Request } from "@playwright/test"; -import { - PROCUREMENT_LOCK_NAME, - PROCUREMENT_RUN_KEY, - chooseProcurementExample, - expectAcceptedEvidence, -} from "./try-dacs-fixtures.js"; - -const LIVE_ENABLED = process.env.RUN_LIVE_PAID_E2E === "1"; -const LIVE_MAX_DEM = Number(process.env.LIVE_E2E_MAX_DEM ?? "0"); -const LIVE_BUTLER = (process.env.LIVE_BUTLER_ORIGIN ?? "https://butler.agentcommerce.network").replace(/\/$/, ""); -const HARD_MAX_DEM = 5; - -function isProcurementPost(request: Request) { - return request.method() === "POST" && new URL(request.url()).pathname === "/demo/procurement"; -} - -test.describe("/try live paid procurement", () => { - test.describe.configure({ mode: "serial" }); - test.skip(!LIVE_ENABLED, "Set RUN_LIVE_PAID_E2E=1 and LIVE_E2E_MAX_DEM to explicitly authorize one live testnet purchase."); - - let context: BrowserContext; - let page: Page; - let idempotencyKey = ""; - let jobId = ""; - let submittedInput: Record = {}; - let submittedGoal = ""; - let paymentTx = ""; - - test.beforeAll(async ({ browser }) => { - if (!Number.isFinite(LIVE_MAX_DEM) || LIVE_MAX_DEM < 1 || LIVE_MAX_DEM > HARD_MAX_DEM) { - throw new Error(`LIVE_E2E_MAX_DEM must be between 1 and ${HARD_MAX_DEM}; the suite will not dispatch a paid request otherwise.`); - } - context = await browser.newContext(); - page = await context.newPage(); - }); - - test.afterAll(async () => { - await context?.close(); - }); - - test("1. completes one real paid purchase", async ({}, testInfo) => { - test.setTimeout(13 * 60_000); - await chooseProcurementExample(page, "pay-dem"); - await page.locator("#proc-budget").fill(String(LIVE_MAX_DEM)); - - const startResponse = page.waitForResponse((response) => isProcurementPost(response.request())); - await page.getByRole("button", { name: /Run the full deal/ }).click(); - const response = await startResponse; - const request = response.request(); - const startBody = await response.json() as { id?: string; error?: unknown }; - - idempotencyKey = (await request.headerValue("idempotency-key")) ?? ""; - submittedInput = JSON.parse(request.postData() ?? "{}") as Record; - submittedGoal = String(submittedInput.goal ?? "live E2E procurement"); - jobId = String(startBody.id ?? ""); - - expect(response.ok(), JSON.stringify(startBody)).toBe(true); - expect(idempotencyKey).toBeTruthy(); - expect(jobId).toBeTruthy(); - expect(submittedInput.paymentRail).toBe("pay-dem"); - expect(Number(submittedInput.budgetDem)).toBeLessThanOrEqual(LIVE_MAX_DEM); - - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible({ timeout: 12 * 60_000 }); - paymentTx = (await page.locator(".tx-link code").textContent())?.trim() ?? ""; - expect(paymentTx).toBeTruthy(); - - await testInfo.attach("live-purchase.json", { - contentType: "application/json", - body: Buffer.from(JSON.stringify({ jobId, idempotencyKey, paymentTx, budgetCapDem: LIVE_MAX_DEM }, null, 2)), - }); - }); - - test("2. verifies the real post-payment evidence and all five DACS stages", async ({}, testInfo) => { - await expectAcceptedEvidence(page); - const response = await context.request.get(`${LIVE_BUTLER}/demo/procurement/${encodeURIComponent(jobId)}`); - expect(response.ok()).toBe(true); - const job = await response.json() as Record; - const result = job.result as Record; - const settlement = result.settlement as Record; - const amount = Number(settlement.amountDem ?? settlement.amount); - const transactions = Array.isArray(result.transactions) ? result.transactions as Array> : []; - - expect(amount).toBeGreaterThan(0); - expect(amount).toBeLessThanOrEqual(LIVE_MAX_DEM); - expect(String(settlement.txHash)).toBe(paymentTx); - expect(transactions.some((transaction) => transaction.kind === "payment" && transaction.txRef === paymentTx)).toBe(true); - - await testInfo.attach("live-evidence.json", { - contentType: "application/json", - body: Buffer.from(JSON.stringify({ jobId, paymentTx, amountDem: amount, events: job.events, result }, null, 2)), - }); - }); - - test("3. reload recovery reads the existing job and sends no second POST", async () => { - const record = { runId: idempotencyKey, jobId, goal: submittedGoal, input: submittedInput, startedAt: new Date().toISOString() }; - await page.evaluate(({ key, value }) => localStorage.setItem(key, JSON.stringify(value)), { key: PROCUREMENT_RUN_KEY, value: record }); - await page.reload(); - await expect(page.locator(".resume-banner")).toContainText(jobId.slice(0, 8)); - - let posts = 0; - const countPosts = (request: Request) => { if (isProcurementPost(request)) posts += 1; }; - page.on("request", countPosts); - const statusResponse = page.waitForResponse((response) => - response.request().method() === "GET" && new URL(response.url()).pathname === `/demo/procurement/${jobId}`, - ); - await page.getByRole("button", { name: /Check & resume/ }).click(); - expect((await statusResponse).ok()).toBe(true); - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible(); - page.off("request", countPosts); - - expect(posts).toBe(0); - await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - }); - - test("4. a real second tab is refused before another paid POST", async () => { - const protectedRecord = { - runId: idempotencyKey, - jobId, - goal: submittedGoal, - input: submittedInput, - startedAt: new Date().toISOString(), - }; - const serialized = JSON.stringify(protectedRecord); - await page.evaluate(({ key, value }) => localStorage.setItem(key, value), { key: PROCUREMENT_RUN_KEY, value: serialized }); - const secondTab = await context.newPage(); - await secondTab.goto("/try"); - await expect(secondTab.locator(".resume-banner")).toContainText("still on record"); - - let posts = 0; - const countPosts = (request: Request) => { if (isProcurementPost(request)) posts += 1; }; - secondTab.on("request", countPosts); - await secondTab.getByRole("button", { name: /Security Auditor/ }).first().click(); - await secondTab.getByRole("button", { name: "Load example" }).click(); - await secondTab.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(secondTab.locator(".bubble.error")).toContainText("earlier procurement run from this browser is still on record"); - expect(posts).toBe(0); - expect(await secondTab.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBe(serialized); - secondTab.off("request", countPosts); - await secondTab.close(); - await page.evaluate((key) => localStorage.removeItem(key), PROCUREMENT_RUN_KEY); - }); - - test("5. cancelling a queued cross-tab lock sends no paid POST", async () => { - await page.evaluate((key) => localStorage.removeItem(key), PROCUREMENT_RUN_KEY); - const actor = await context.newPage(); - await page.goto("/try"); - await chooseProcurementExample(actor); - - await page.evaluate((lockName) => { - const scope = window as typeof window & { - __liveE2eLockHeld?: boolean; - __liveE2eReleaseLock?: () => void; - }; - if (!navigator.locks) throw new Error("Web Locks unavailable in the live E2E browser"); - scope.__liveE2eLockHeld = false; - void navigator.locks.request(lockName, async () => { - scope.__liveE2eLockHeld = true; - await new Promise((resolve) => { scope.__liveE2eReleaseLock = resolve; }); - }); - }, PROCUREMENT_LOCK_NAME); - await expect.poll(() => page.evaluate(() => Boolean((window as typeof window & { __liveE2eLockHeld?: boolean }).__liveE2eLockHeld))).toBe(true); - - let posts = 0; - const countPosts = (request: Request) => { if (isProcurementPost(request)) posts += 1; }; - actor.on("request", countPosts); - await actor.getByRole("button", { name: /Run the full deal/ }).click(); - await expect(actor.getByText(/FULL DACS FLOW · DEM · Demos/)).toBeVisible(); - await actor.getByRole("button", { name: /Stop watching/ }).click(); - await page.evaluate(() => (window as typeof window & { __liveE2eReleaseLock?: () => void }).__liveE2eReleaseLock?.()); - - await expect(actor.locator(".bubble.error")).toContainText("Run cancelled in this browser"); - expect(posts).toBe(0); - expect(await actor.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - actor.off("request", countPosts); - await actor.close(); - }); -}); diff --git a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts b/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts deleted file mode 100644 index 7deaf6d..0000000 --- a/reference-implementations/dacs-directory/e2e/try-dacs.spec.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { expect, test, type Route } from "@playwright/test"; -import { - PROCUREMENT_LOCK_NAME, - PROCUREMENT_RUN_KEY, - chooseProcurementExample, - completedJob, - expectAcceptedEvidence, - failedSecurityPreviewJob, - installMockGateway, - securityPreviewJob, - x402CompletedJob, -} from "./try-dacs-fixtures.js"; - -async function fulfillJson(route: Route, body: unknown) { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(body), - headers: { "access-control-allow-origin": "*" }, - }); -} - -test.describe("/try procurement browser safety", () => { - test("1. completes a paid-style procurement and clears its recovery record", async ({ context, page }) => { - await installMockGateway(context); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expectAcceptedEvidence(page); - await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - }); - - test("2. reload recovery reuses the original idempotency key", async ({ context, page }) => { - const keys: string[] = []; - let posts = 0; - await installMockGateway(context, { - onProcurementPost: async (route) => { - posts += 1; - keys.push((await route.request().headerValue("idempotency-key")) ?? ""); - if (posts === 1) await route.abort("failed"); - else await fulfillJson(route, completedJob); - }, - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - await expect(page.locator(".bubble.error")).toContainText("Retrying reuses the same idempotency key"); - const storedBeforeReload = await page.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY); - expect(storedBeforeReload).not.toBeNull(); - - await page.reload(); - await expect(page.locator(".resume-banner")).toContainText("the job id was never received"); - await page.getByRole("button", { name: /Check & resume/ }).click(); - - await expectAcceptedEvidence(page); - expect(posts).toBe(2); - expect(keys[0]).toBeTruthy(); - expect(keys[1]).toBe(keys[0]); - }); - - test("3. a second tab cannot overwrite an active procurement record", async ({ context, page }) => { - let posts = 0; - let pendingRoute: Route | undefined; - let releasePost!: (action: "abort") => void; - const postGate = new Promise<"abort">((resolve) => { releasePost = resolve; }); - let firstPostSeen!: () => void; - const firstPost = new Promise((resolve) => { firstPostSeen = resolve; }); - - await installMockGateway(context, { - onProcurementPost: async (route) => { - posts += 1; - pendingRoute = route; - firstPostSeen(); - await postGate; - await route.abort("failed"); - }, - }); - - const secondTab = await context.newPage(); - await secondTab.goto("/try"); - await expect(secondTab.getByRole("button", { name: /Security Auditor/ }).first()).toBeVisible(); - await chooseProcurementExample(page); - await page.getByRole("button", { name: /Run the full deal/ }).click(); - await firstPost; - - await expect(secondTab.locator(".resume-banner")).toContainText("still on record"); - const recordBefore = await secondTab.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY); - expect(recordBefore).not.toBeNull(); - - await secondTab.getByRole("button", { name: /Security Auditor/ }).first().click(); - await secondTab.getByRole("button", { name: "Load example" }).click(); - await secondTab.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(secondTab.locator(".bubble.error")).toContainText("earlier procurement run from this browser is still on record"); - expect(posts).toBe(1); - expect(await secondTab.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBe(recordBefore); - - releasePost("abort"); - await expect.poll(() => pendingRoute === undefined || posts === 1).toBeTruthy(); - await secondTab.close(); - }); - - test("4. cancelling while queued for the Web Lock never dispatches later", async ({ context }) => { - let posts = 0; - await installMockGateway(context, { - onProcurementPost: async (route) => { - posts += 1; - await fulfillJson(route, completedJob); - }, - }); - - const lockHolder = await context.newPage(); - const actor = await context.newPage(); - await lockHolder.goto("/try"); - await chooseProcurementExample(actor); - - await lockHolder.evaluate((lockName) => { - const scope = window as typeof window & { - __e2eLockHeld?: boolean; - __e2eReleaseLock?: () => void; - }; - if (!navigator.locks) throw new Error("Web Locks unavailable in the E2E browser"); - scope.__e2eLockHeld = false; - void navigator.locks.request(lockName, async () => { - scope.__e2eLockHeld = true; - await new Promise((resolve) => { scope.__e2eReleaseLock = resolve; }); - }); - }, PROCUREMENT_LOCK_NAME); - await expect.poll(() => lockHolder.evaluate(() => Boolean((window as typeof window & { __e2eLockHeld?: boolean }).__e2eLockHeld))).toBe(true); - - await actor.getByRole("button", { name: /Run the full deal/ }).click(); - await expect(actor.getByText(/FULL DACS FLOW · DEM · Demos/)).toBeVisible(); - await actor.getByRole("button", { name: /Stop watching/ }).click(); - await lockHolder.evaluate(() => (window as typeof window & { __e2eReleaseLock?: () => void }).__e2eReleaseLock?.()); - - await expect(actor.locator(".bubble.error")).toContainText("Run cancelled in this browser"); - expect(posts).toBe(0); - expect(await actor.evaluate((key) => localStorage.getItem(key), PROCUREMENT_RUN_KEY)).toBeNull(); - }); - - test("5. renders all five stages and the post-payment evidence", async ({ context, page }) => { - const runningJob = { - id: completedJob.id, - status: "running", - phase: "discovering", - events: completedJob.events.slice(0, 1), - }; - await installMockGateway(context, { - onProcurementPost: (route) => fulfillJson(route, runningJob), - onProcurementGet: (route) => fulfillJson(route, completedJob), - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expectAcceptedEvidence(page); - await expect(page.locator(".tx-link code")).toHaveText("mock-payment-transaction"); - await expect(page.locator(".chain-activity .chain-row")).toHaveCount(completedJob.events.length); - await expect(page.getByText("Full evidence bundle accepted & reconciled", { exact: true })).toBeVisible(); - }); - - test("6. switches to the x402 schema and submits the USDC rail explicitly", async ({ context, page }) => { - let submitted: Record | undefined; - await installMockGateway(context, { - onProcurementPost: async (route) => { - submitted = JSON.parse(route.request().postData() ?? "{}") as Record; - await fulfillJson(route, x402CompletedJob); - }, - }); - await chooseProcurementExample(page, "pay-x402"); - - await expect(page.getByText("Operator-provisional rail authority", { exact: true })).toBeVisible(); - await expect(page.getByLabel("USDC budget")).toHaveValue("0.1"); - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Base Sepolia USDC settlement" })).toBeVisible(); - await expect(page.getByText("settled & seller-verified", { exact: true })).toBeVisible(); - await expect(page.getByText("Settled & accepted", { exact: true })).toBeVisible(); - expect(submitted?.profileId).toBe("security-audit-rfq"); - expect(submitted?.paymentRail).toBe("pay-x402"); - expect(submitted?.budgetUsdc).toBe(0.1); - expect(submitted?.budgetDem).toBeUndefined(); - }); - - test("7. required Oracle parameters block dispatch and identify the invalid field", async ({ context, page }) => { - await installMockGateway(context); - await page.goto("/try"); - await page.getByRole("button", { name: /Oracle Desk/ }).first().click(); - - const run = page.getByRole("button", { name: /Run the full deal/ }); - const coin = page.getByLabel("Coin"); - await expect(run).toBeEnabled(); - await coin.fill(""); - await expect(run).toBeDisabled(); - await expect(coin).toHaveAttribute("aria-invalid", "true"); - await expect(page.getByText("Required — enter a CoinGecko coin id.")).toBeVisible(); - - await page.getByLabel("Data product").selectOption("fx-rate"); - const base = page.getByLabel("From currency"); - const quote = page.getByLabel("To currency"); - await expect(run).toBeEnabled(); - - await base.fill(""); - await expect(run).toBeDisabled(); - await expect(base).toHaveAttribute("aria-invalid", "true"); - await expect(page.getByText("Required — enter the currency to convert from.")).toBeVisible(); - - await base.fill("USD"); - await quote.fill(""); - await expect(run).toBeDisabled(); - await expect(quote).toHaveAttribute("aria-invalid", "true"); - await expect(page.getByText("Required — enter the currency to convert to.")).toBeVisible(); - }); - - test("8. shows verified delivery while DACS-5 remains nonterminal, then accepts only the completed job", async ({ context, page }) => { - let releaseFinalPoll!: () => void; - const finalPollGate = new Promise((resolve) => { releaseFinalPoll = resolve; }); - let previewReturned!: () => void; - const previewSeen = new Promise((resolve) => { previewReturned = resolve; }); - - await installMockGateway(context, { - onProcurementPost: async (route) => { - await fulfillJson(route, securityPreviewJob); - previewReturned(); - }, - onProcurementGet: async (route) => { - await finalPollGate; - await fulfillJson(route, completedJob); - }, - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - await previewSeen; - - await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toBeVisible(); - await expect(page.getByText("DACS-5 finalising", { exact: true })).toBeVisible(); - await expect(page.getByText("Not settled-and-accepted yet", { exact: true })).toBeVisible(); - await expect(page.getByText("Unsafe dynamic execution", { exact: true })).toBeVisible(); - await expect(page.getByText(/DELIVERY VERIFIED · DACS-5 FINALISING/)).toBeVisible(); - await expect(page.locator(".journey-step.active").filter({ hasText: "Verify" })).toContainText("two DACS-5 copies are finalising"); - await expect(page.locator(".journey-step.active")).toHaveCount(1); - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toHaveCount(0); - await expect(page.getByText("Settled & accepted", { exact: true })).toHaveCount(0); - - releaseFinalPoll(); - await expectAcceptedEvidence(page); - await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toHaveCount(0); - }); - - test("9. preserves a verified delivery when DACS-5 finalisation fails without claiming acceptance", async ({ context, page }) => { - await installMockGateway(context, { - onProcurementPost: (route) => fulfillJson(route, securityPreviewJob), - onProcurementGet: (route) => fulfillJson(route, failedSecurityPreviewJob), - }); - await chooseProcurementExample(page); - - await page.getByRole("button", { name: /Run the full deal/ }).click(); - - await expect(page.getByRole("heading", { name: "Verified security report is ready" })).toBeVisible(); - await expect(page.getByText("DACS-5 needs recovery", { exact: true })).toBeVisible(); - await expect(page.getByText("Finalisation stopped safely", { exact: true })).toBeVisible(); - await expect(page.getByText(failedSecurityPreviewJob.error, { exact: true }).first()).toBeVisible(); - await expect(page.getByText("Unsafe dynamic execution", { exact: true })).toBeVisible(); - await expect(page.getByText("Not settled-and-accepted yet", { exact: true })).toBeVisible(); - await expect(page.getByText("Settled & accepted", { exact: true })).toHaveCount(0); - await expect(page.getByRole("heading", { name: "Security Auditor result" })).toHaveCount(0); - await expect(page.getByText(/Retrying reuses the same idempotency key/)).toHaveCount(0); - }); -}); diff --git a/reference-implementations/dacs-directory/package.json b/reference-implementations/dacs-directory/package.json index f3937b4..4b174b1 100644 --- a/reference-implementations/dacs-directory/package.json +++ b/reference-implementations/dacs-directory/package.json @@ -10,11 +10,9 @@ "build": "next build", "start": "next start -p ${PORT:-3400}", "start:railway": "bash scripts/start-railway.sh", - "check:deploy-config": "node scripts/check-butler-origin.mjs", - "check:butler": "node scripts/check-butler-origin.mjs --probe", + "check:deploy-config": "node scripts/check-directory-origin.mjs", "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:live": "playwright test e2e/try-dacs.live.spec.ts", + "test:e2e": "playwright test e2e/home.spec.ts e2e/register.spec.ts", "test:e2e:ui": "playwright test --ui", "test:seed": "tsx --test test/seed-smoke.test.ts", "index": "tsx src/catalog/reindex.ts", diff --git a/reference-implementations/dacs-directory/playwright.config.ts b/reference-implementations/dacs-directory/playwright.config.ts index e25f23e..cd31cb0 100644 --- a/reference-implementations/dacs-directory/playwright.config.ts +++ b/reference-implementations/dacs-directory/playwright.config.ts @@ -1,16 +1,12 @@ import { defineConfig, devices } from "@playwright/test"; -const butlerOrigin = process.env.LIVE_BUTLER_ORIGIN ?? "https://butler.agentcommerce.network"; -const livePaidRun = process.env.RUN_LIVE_PAID_E2E === "1"; - export default defineConfig({ testDir: "./e2e", - outputDir: livePaidRun ? "test-results/playwright-live" : "test-results/playwright", + outputDir: "test-results/playwright", fullyParallel: false, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 1 : 0, - workers: livePaidRun ? 1 : undefined, - reporter: [["list"], ["html", { open: "never", outputFolder: livePaidRun ? "playwright-report-live" : "playwright-report" }]], + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], use: { baseURL: "http://localhost:3400", trace: "retain-on-failure", @@ -25,12 +21,11 @@ export default defineConfig({ ], webServer: { command: "npm run dev", - url: "http://localhost:3400/try", + url: "http://localhost:3400/", reuseExistingServer: !process.env.CI, timeout: 120_000, env: { NEXT_PUBLIC_DIRECTORY_URL: "http://localhost:3400", - NEXT_PUBLIC_BUTLER_ORIGIN: butlerOrigin, }, }, }); diff --git a/reference-implementations/dacs-directory/scripts/check-butler-origin.mjs b/reference-implementations/dacs-directory/scripts/check-butler-origin.mjs deleted file mode 100644 index b2223c2..0000000 --- a/reference-implementations/dacs-directory/scripts/check-butler-origin.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -export function httpsOrigin(env, name) { - const raw = env[name]?.trim(); - if (!raw) throw new Error(`${name} is required for a production deployment`); - let url; - try { url = new URL(raw); } - catch { throw new Error(`${name} must be an absolute URL`); } - if (url.protocol !== "https:") throw new Error(`${name} must use HTTPS`); - if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) { - throw new Error(`${name} must be an origin without credentials, path, query, or fragment`); - } - return url.origin; -} - -function headerValues(response, name) { - return (response.headers.get(name) ?? "").toLowerCase().split(",").map((value) => value.trim()).filter(Boolean); -} - -export function assertCorsOrigin(response, directoryOrigin, requestLabel) { - if (response.headers.get("access-control-allow-origin") !== directoryOrigin) { - throw new Error(`Butler gateway does not CORS-allow ${directoryOrigin} for ${requestLabel}`); - } -} - -export function assertJsonPostPreflight(response, directoryOrigin) { - if (!response.ok) throw new Error(`Butler JSON POST preflight returned HTTP ${response.status}`); - assertCorsOrigin(response, directoryOrigin, "JSON POST preflight"); - if (!headerValues(response, "access-control-allow-methods").includes("post")) { - throw new Error("Butler JSON POST preflight does not allow POST"); - } - if (!headerValues(response, "access-control-allow-headers").includes("content-type")) { - throw new Error("Butler JSON POST preflight does not allow content-type"); - } -} - -export async function checkButlerOrigin({ env = process.env, fetcher = fetch, probe = false } = {}) { - const directoryOrigin = httpsOrigin(env, "NEXT_PUBLIC_DIRECTORY_URL"); - const butlerOrigin = httpsOrigin(env, "NEXT_PUBLIC_BUTLER_ORIGIN"); - - if (!probe) return `Production origins valid: directory=${directoryOrigin} butler=${butlerOrigin}`; - - const response = await fetcher(`${butlerOrigin}/demo/butler/agents`, { - headers: { origin: directoryOrigin }, - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) throw new Error(`Butler catalog probe returned HTTP ${response.status}`); - assertCorsOrigin(response, directoryOrigin, "catalog GET"); - const body = await response.json(); - if (!Array.isArray(body?.agents) || body.agents.length === 0) { - throw new Error("Butler catalog probe returned no agents"); - } - - for (const path of ["/demo/procurement", "/demo/butler"]) { - const preflight = await fetcher(`${butlerOrigin}${path}`, { - method: "OPTIONS", - headers: { - origin: directoryOrigin, - "access-control-request-method": "POST", - "access-control-request-headers": "content-type", - }, - signal: AbortSignal.timeout(10_000), - }); - try { assertJsonPostPreflight(preflight, directoryOrigin); } - catch (cause) { throw new Error(`${path}: ${cause instanceof Error ? cause.message : String(cause)}`); } - } - return `Butler gateway ready: ${body.agents.length} agents and JSON POST CORS available to ${directoryOrigin}`; -} - -async function main() { - try { - console.log(await checkButlerOrigin({ probe: process.argv.includes("--probe") })); - } catch (error) { - console.error(`[deployment config] ${error.message}`); - process.exitCode = 1; - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await main(); diff --git a/reference-implementations/dacs-directory/scripts/check-directory-origin.mjs b/reference-implementations/dacs-directory/scripts/check-directory-origin.mjs new file mode 100644 index 0000000..346ceb7 --- /dev/null +++ b/reference-implementations/dacs-directory/scripts/check-directory-origin.mjs @@ -0,0 +1,42 @@ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export function httpsOrigin(env, name) { + const raw = env[name]?.trim(); + if (!raw) throw new Error(`${name} is required for a production deployment`); + + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`${name} must be an absolute URL`); + } + + if (url.protocol !== "https:") throw new Error(`${name} must use HTTPS`); + if ( + url.username + || url.password + || url.search + || url.hash + || (url.pathname !== "/" && url.pathname !== "") + ) { + throw new Error(`${name} must be an origin without credentials, path, query, or fragment`); + } + return url.origin; +} + +export function checkDirectoryOrigin({ env = process.env } = {}) { + const directoryOrigin = httpsOrigin(env, "NEXT_PUBLIC_DIRECTORY_URL"); + return `Production directory origin valid: ${directoryOrigin}`; +} + +async function main() { + try { + console.log(checkDirectoryOrigin()); + } catch (error) { + console.error(`[deployment config] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await main(); diff --git a/reference-implementations/dacs-directory/src/components/HomeDealDemo.tsx b/reference-implementations/dacs-directory/src/components/HomeDealDemo.tsx deleted file mode 100644 index 6381e99..0000000 --- a/reference-implementations/dacs-directory/src/components/HomeDealDemo.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { - SAMPLE_PROCUREMENT_EVENTS, - SPEAKERS, - STAGES, - eventsToConversation, -} from "./try-chat-script.js"; - -const EXPLORER = "https://explorer.demos.sh"; - -function compact(value: unknown, head = 8, tail = 6): string { - const text = String(value ?? ""); - return text.length > head + tail + 1 ? `${text.slice(0, head)}…${text.slice(-tail)}` : text; -} - -/** - * Compact, auto-playing loop of the REAL recorded purchase (the same captured - * run as /try-chat, every tx link genuine). Starts when scrolled into view and - * loops with a short hold on the settled outcome. A visible playback control - * lets mouse, keyboard, and touch users pause or resume it. - */ -export default function HomeDealDemo() { - const turns = useMemo(() => eventsToConversation(SAMPLE_PROCUREMENT_EVENTS), []); - const [visible, setVisible] = useState(0); - const [started, setStarted] = useState(false); - const [paused, setPaused] = useState(false); - const rootRef = useRef(null); - const scrollRef = useRef(null); - - // Begin only when the demo is actually on screen. Users who prefer reduced - // motion get the finished conversation immediately — no reveal, no loop. - useEffect(() => { - const node = rootRef.current; - if (!node || started) return; - const observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setVisible(turns.length); - setPaused(true); - } - setStarted(true); - } - }, { threshold: 0.25 }); - observer.observe(node); - return () => observer.disconnect(); - }, [started, turns.length]); - - // Reveal one turn per beat; hold on the finished deal, then loop. - useEffect(() => { - if (!started || paused) return; - const finished = visible >= turns.length; - const beat = finished ? 6_000 : turns[visible]!.kind === "say" ? 850 : 1_150; - const timer = setTimeout(() => setVisible(finished ? 0 : visible + 1), beat); - return () => clearTimeout(timer); - }, [started, paused, visible, turns]); - - // Keep the newest turn in view inside the demo's own scroll area. - useEffect(() => { - const el = scrollRef.current; - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - }, [visible]); - - const shown = turns.slice(0, visible); - const stage = shown.length ? shown[shown.length - 1]!.stage : 0; - const settled = visible >= turns.length; - - return ( -
-
- recorded deal · sec-audit via rfq -
-
- {STAGES.map((item, index) => ( - index ? "done" : stage === index && shown.length ? "active" : ""} title={`${item.primitive} ${item.name}`}> - {settled || stage > index ? "✓" : index + 1} - - ))} -
- -
-
-
- {shown.map((turn) => { - const who = SPEAKERS[turn.speaker]; - return ( -
- {who.side !== "center" && {who.avatar}} -
- {who.side !== "center" && {who.name}} -

{turn.text}

- {(turn.txRef || turn.anchorRef) && ( -
- {turn.kind === "pay" ? "payment" : "receipt"} - {turn.txRef - ? tx {compact(turn.txRef)} ↗ - : {compact(turn.anchorRef, 10, 5)}} -
- )} -
-
- ); - })} - {!settled && started &&
} - {settled && ( -
settled · five receipts on-chain
- )} -
-
- recorded on-chain purchase - try dacs → -
-
- ); -} diff --git a/reference-implementations/dacs-directory/src/components/SiteNav.tsx b/reference-implementations/dacs-directory/src/components/SiteNav.tsx index a75d29c..bfa9fc0 100644 --- a/reference-implementations/dacs-directory/src/components/SiteNav.tsx +++ b/reference-implementations/dacs-directory/src/components/SiteNav.tsx @@ -6,9 +6,7 @@ import ThemeToggle from "./ThemeToggle"; const LINKS = [ { href: "/", label: "home" }, - { href: "/discover", label: "discover" }, - { href: "/try-chat", label: "watch a deal" }, - { href: "/try", label: "try dacs", featured: true }, + { href: "/discover", label: "discover", featured: true }, { href: "/how-it-works", label: "how it works" }, { href: "/verify", label: "verify" }, { href: "/register", label: "list your service" }, @@ -65,7 +63,7 @@ export default function SiteNav() { setOpen(false)} > {link.label}{link.featured ? : null} diff --git a/reference-implementations/dacs-directory/src/components/TryChat.tsx b/reference-implementations/dacs-directory/src/components/TryChat.tsx deleted file mode 100644 index 5e98016..0000000 --- a/reference-implementations/dacs-directory/src/components/TryChat.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - SAMPLE_PROCUREMENT_EVENTS, - SPEAKERS, - STAGES, - eventsToConversation, - type ConversationTurn, -} from "./try-chat-script.js"; - -const EXPLORER = "https://explorer.demos.sh"; - -function compact(value: unknown, head = 8, tail = 6): string { - const text = String(value ?? ""); - return text.length > head + tail + 1 ? `${text.slice(0, head)}…${text.slice(-tail)}` : text; -} - -type Mode = "idle" | "replay" | "done"; - -export default function TryChat() { - const [mode, setMode] = useState("idle"); - const [visible, setVisible] = useState(0); // turns revealed so far (replay pacing) - const [showTech, setShowTech] = useState(false); - const transcriptRef = useRef(null); - - const turns = useMemo(() => eventsToConversation(SAMPLE_PROCUREMENT_EVENTS), []); - const shown = mode === "replay" ? turns.slice(0, visible) : mode === "idle" ? [] : turns; - const currentStage = shown.length ? shown[shown.length - 1]!.stage : 0; - const complete = mode === "done"; - - // Auto-scroll the transcript as turns appear. - useEffect(() => { - const el = transcriptRef.current; - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - }, [shown.length, mode]); - - // Replay pacing: reveal one turn at a time with a human-readable beat. - useEffect(() => { - if (mode !== "replay") return; - if (visible >= turns.length) { setMode("done"); return; } - const turn = turns[visible]!; - const beat = turn.kind === "anchor" ? 900 : turn.kind === "pay" ? 1100 : 700; - const timer = setTimeout(() => setVisible((n) => n + 1), beat); - return () => clearTimeout(timer); - }, [mode, visible, turns]); - - function startReplay() { - setVisible(0); - setMode("replay"); - } - - function showFullReplay() { - setVisible(turns.length); - setMode("done"); - } - - return ( -
-
-
recorded deal · sec-audit via rfq
-

A recorded deal between two agents.

-

- The Butler (left) buys a code audit from the Auditor (right). - Each step anchors a receipt on the Demos chain — this is the evidence from one - completed purchase. -

-
- - {mode === "replay" && } - - Run a live deal choose agent · DEM or x402 - - -
-
- -
- Recorded RFQ replay · job d27cd332 · 20 July 2026 - Replays one completed purchase — never starts a job or spends funds. Run one live at Try DACS. -
- -
- {STAGES.map((stage, index) => { - const state = complete || currentStage > index ? "done" : (shown.length && currentStage === index ? "active" : "todo"); - return ( -
- {state === "done" ? "✓" : index + 1} -
{stage.name}{stage.primitive}

{stage.blurb}

-
- ); - })} -
- -
- Butler — buyer's agent - Auditor — seller's agent - Demos chain — the public receipt - EvalBot — independent judge -
- -
- {shown.length === 0 && ( -
-

Press Watch the recorded deal to see a genuine purchase play out, step by step.

- The replay uses a genuine on-chain purchase captured from the live network — every transaction link is real. -
- )} - {shown.map((turn, index) => ( - - ))} - {mode === "replay" && !complete && ( -
- )} -
- - {complete && ( -
-
✓ Recorded deal settled & verified
-

The Butler got its audit, the Auditor got paid, and the entire deal is now a chain of signed receipts anyone can re-check — the listing, the identity vet, the signed terms, the payment, and the delivery. That is DACS.

-
- - Run a live procurement → -
-
- )} -
- ); -} - -function ChatTurn({ turn, newStage, showTech }: { turn: ConversationTurn; newStage: boolean; showTech: boolean }) { - const meta = SPEAKERS[turn.speaker]; - const stage = STAGES[turn.stage]!; - return ( - <> - {newStage && ( -
{stage.primitive}{stage.name}{stage.blurb}
- )} -
- {meta.side !== "center" && {meta.avatar}} -
- {meta.side !== "center" && {meta.name} · {meta.role}} -

{turn.text}

- {(turn.txRef || turn.anchorRef) && ( -
- {turn.kind === "pay" ? "payment" : "receipt"} - {turn.txRef - ? verify tx {compact(turn.txRef)} ↗ - : {compact(turn.anchorRef, 12, 6)}} -
- )} - {showTech &&
gateway event{turn.raw}
} -
-
- - ); -} diff --git a/reference-implementations/dacs-directory/src/components/TryDacs.tsx b/reference-implementations/dacs-directory/src/components/TryDacs.tsx deleted file mode 100644 index 5fcb84d..0000000 --- a/reference-implementations/dacs-directory/src/components/TryDacs.tsx +++ /dev/null @@ -1,1229 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - AGENT_TIMEOUT_MESSAGE, - ButlerContractError, - LIVE_PROCUREMENT_PROFILE_IDS, - fetchJsonBeforeDeadline, - fetchJsonWithTimeout, - parseAgentInput, - parseButlerRun, - parseProcurementProfiles, - parseProcurementJob, - parseReceiptEnvelope, - procurementProfileCard, - procurementEvidence, - record, - type AgentCard, - type OutputReceipt, - type PaymentRail, - type ProcurementEvent, - type ProcurementJob, - type ProcurementPreview, - type ProcurementProfile, -} from "./try-dacs-contract.js"; -import { - flattenInputKeys, - hasBuiltinForm, - initialAgentInput, - mapGatewayErrors, - initialSchemaInput, - parseAgentFieldSchema, - summarizeAgentInput, - validateAgentInput, - validateSchemaInput, - type FieldErrors, -} from "./try-dacs-forms.js"; -import AgentInputForm from "./try-forms/AgentInputForm.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 PROCUREMENT_RUN_KEY = "dacs-try:procurement-run"; -const PROFILE_AGENT: Record = { - "oracle-auto-accept": { name: "oracle-desk", label: "Oracle Desk" }, - "dd-live-fixed": { name: "dd-researcher", label: "Due-Diligence Researcher" }, - "security-audit-rfq": { name: "procurement-butler", label: "Security Auditor" }, -}; - -function readStoredRun(): StoredProcurementRun | null { - try { return parseStoredProcurementRun(window.localStorage.getItem(PROCUREMENT_RUN_KEY)); } catch { return null; } -} - -/** Thrown when a queued resume finds its captured record was reconciled by another tab. */ -class StaleResumeRecordError extends Error { - constructor() { - super("This recovery record was reconciled in another tab (dismissed, resumed there, or replaced by a new run), so nothing was sent from this tab."); - this.name = "StaleResumeRecordError"; - } -} - -/** The browser's Web Locks manager, or undefined where unsupported. */ -function browserLocks(): LockRequestor | undefined { - return typeof navigator !== "undefined" && navigator.locks - ? (navigator.locks as unknown as LockRequestor) - : undefined; -} - -/** - * Delete the persisted record ONLY if it still belongs to `runId` — another - * tab's record must never be deleted by this tab's cleanup or 4xx handling. - */ -function releaseStoredRunIfOwned(runId: string | null): void { - try { - const current = parseStoredProcurementRun(window.localStorage.getItem(PROCUREMENT_RUN_KEY)); - if (current && current.runId !== runId) return; - window.localStorage.removeItem(PROCUREMENT_RUN_KEY); - } catch { /* storage unavailable — nothing to release */ } -} -/** - * Write the run record and VERIFY it by reading it back. Returns false when - * durable persistence cannot be established (storage blocked, full, or the - * read-back doesn't match) — the caller must refuse to dispatch a paid - * request in that case, because a reload would lose the idempotency key. - */ -function writeStoredRun(run: StoredProcurementRun | null): boolean { - try { - if (run) { - window.localStorage.setItem(PROCUREMENT_RUN_KEY, JSON.stringify(run)); - const back = parseStoredProcurementRun(window.localStorage.getItem(PROCUREMENT_RUN_KEY)); - return back !== null && back.runId === run.runId && back.jobId === run.jobId; - } - window.localStorage.removeItem(PROCUREMENT_RUN_KEY); - return window.localStorage.getItem(PROCUREMENT_RUN_KEY) === null; - } catch { - return false; - } -} - -type Plan = { - butler: { selectedAgent: string; label: string; rationale: string; selectionEngine: string; alternatives: string[] }; - proposedInput: Record; - inputNote: string; -}; - -const EXPLORER = "https://explorer.demos.sh"; -const BASE_SEPOLIA_EXPLORER = "https://sepolia.basescan.org"; -const AGENT_TIMEOUT_MS = 120_000; -const RECEIPT_WATCH_TIMEOUT_MS = 2 * 60_000; - -function compact(value: unknown, head = 18, tail = 8): string { - const text = String(value ?? ""); - return text.length > head + tail + 1 ? `${text.slice(0, head)}…${text.slice(-tail)}` : text; -} - -function elapsedLabel(ms: number): string { - return ms < 10_000 ? `${(ms / 1_000).toFixed(1)}s` : `${Math.floor(ms / 1_000)}s`; -} - -function paymentRailLabel(rail: PaymentRail): string { - return rail === "pay-x402" ? "USDC · x402" : "DEM · Demos"; -} - -function transactionExplorer(txRef: string): string { - return /^0x[0-9a-f]{64}$/i.test(txRef) - ? `${BASE_SEPOLIA_EXPLORER}/tx/${encodeURIComponent(txRef)}` - : `${EXPLORER}/transactions/${encodeURIComponent(txRef)}`; -} - -function defaultPaymentRail(profile: ProcurementProfile): PaymentRail { - return profile.paymentRails.find((rail) => profile.railReadiness[rail]?.executable) ?? profile.paymentRails[0]!; -} - -function procurementModeLabel(mode: string): string { - if (mode === "fixed-price-auto-accept") return "Fixed price · auto-accept"; - if (mode === "fixed-price-live-cosign") return "Fixed price · live co-sign"; - if (mode === "rfq") return "RFQ · negotiated price"; - return mode.replaceAll("-", " "); -} - -function waitWithSignal(ms: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal.aborted) return reject(new DOMException("aborted", "AbortError")); - const timer = setTimeout(done, ms); - function done() { signal.removeEventListener("abort", aborted); resolve(); } - function aborted() { clearTimeout(timer); reject(new DOMException("aborted", "AbortError")); } - signal.addEventListener("abort", aborted, { once: true }); - }); -} - -function ProcurementReport({ value, events, profile, rail }: { value: unknown; events: ProcurementEvent[]; profile: ProcurementProfile; rail: PaymentRail }) { - const report = record(value); - const evidence = procurementEvidence(report, profile.mode); - const decision = record(report.decision); - const winner = record(decision.winner); - const settlement = record(report.settlement); - const negotiation = record(report.negotiation); - const terms = record(negotiation.terms); - const delivery = record(report.delivery); - const audit = record(delivery.report); - const amount = record(settlement.amount); - const termPrice = record(terms.price); - const evaluation = record(report.evaluation); - const ruling = record(evaluation.ruling); - const anchors = record(report.anchors); - const candidates = Array.isArray(decision.candidates) ? decision.candidates.map(record) : []; - const findings = Array.isArray(audit.findings) ? audit.findings.map(record) : []; - const transactions = Array.isArray(report.transactions) ? report.transactions.map(record) : []; - const paymentHash = String(settlement.txHash ?? ""); - const settlementRail: PaymentRail = settlement.rail === "pay-x402" ? "pay-x402" : rail; - const x402 = settlementRail === "pay-x402"; - const railGovernance = record(settlement.railGovernance); - const price = settlement.amountDem ?? amount.amount ?? settlement.amount ?? winner.price; - const currency = String(amount.currency ?? amount.unit ?? "DEM"); - const agreedPrice = termPrice.amount ?? (Object.keys(termPrice).length ? price : terms.price) ?? price; - const acceptedClass = evidence.overallAccepted ? "success-text" : "error-text"; - const acceptanceLabel = evidence.overallAccepted ? "Settled & accepted" : "Verification incomplete"; - const verificationLabel = evidence.overallAccepted ? "accepted" : "not accepted"; - const evaluationLabel = evidence.rulingAccepted && evidence.rulingValid - ? "accept · signature valid" - : `${String(ruling.verdict ?? "not reported")} · ${evidence.rulingValid ? "signature valid" : "signature unverified"}`; - const negotiationLabel = evidence.negotiationSigned ? "dual-signed" : evidence.negotiationVerified ? "signed agreement anchored" : "agreement evidence missing"; - const reportKind = String(audit.kind ?? ""); - - function check(label: string, detail: string, ok: boolean) { - return
{ok ? "✓" : "!"}{label}{detail}
; - } - - return ( -
-
-
OUTCOME{acceptanceLabel}
-
SELECTED SELLER{String(winner.provider ?? profile.agentName)}
-
PRICE{price === undefined ? "not reported" : `${String(price)} ${currency}`}
-
PROCUREMENT TYPE{profile.mode.replaceAll("-", " ")}
-
- -
-
REAL PAYMENT · {paymentRailLabel(settlementRail)}

{x402 ? "Base Sepolia USDC settlement" : "Demos native settlement"}

{evidence.paymentRecorded ? (x402 ? "settled & seller-verified" : "broadcast & recorded") : "evidence missing"}
- {paymentHash ? - PAYMENT TX{paymentHash}View on explorer ↗ - :
PAYMENT TXnot reported
} -
payer {compact(settlement.payer)}seller {compact(settlement.payee)}
- {x402 && Object.keys(railGovernance).length > 0 &&
-
{railGovernance.conformantAuthority === true ? "Canonical rail authority" : "Operator-provisional rail"}{railGovernance.conformantAuthority === true ? "The configured authority is conformant." : "Live for this demo, pending a standardised DACS rail authority."}
- {typeof railGovernance.disclosure === "string" && Governance disclosure ↗} -
} -
- -
-
LIVE NEGOTIATION

{profile.mode === "rfq" ? "Buyer and seller negotiated over L2PS" : "Buyer accepted the seller's published price"}

{negotiationLabel}
-
- {check("Protocol", String(negotiation.protocol ?? "not reported"), Boolean(negotiation.protocol))} - {check("Mode", profile.mode.replaceAll("-", " "), true)} - {check("Agreement", String(negotiation.agreementHash ?? "not reported"), evidence.negotiationVerified)} - {check("Agreed price", agreedPrice === undefined ? "not reported" : `${String(agreedPrice)} ${currency}`, agreedPrice !== undefined)} -
-
Signed negotiation transcript and agreement hash
{JSON.stringify(negotiation, null, 2)}
-
- -
-
PROCUREMENT ROUTE

{profile.title}

{profile.negotiationPhase.replace("negotiate-", "")}
- {candidates.length ?
-
ProviderPriceRailDecision
- {candidates.map((candidate, index) =>
{String(candidate.provider ?? "candidate")}{compact(candidate.listingId, 12, 6)}{String(candidate.askPrice ?? "—")} {candidate.chosenRail === "pay-x402" ? "USDC" : "DEM"}{String(candidate.chosenRail ?? "—")}{candidate.excluded ? String(candidate.excluded) : "selected ✓"}
)} -
:

{profile.summary} The gateway resolved the configured signed listing and bound it into this deal.

} -
- -
-
DELIVERED REPORT

{profile.id === "oracle-auto-accept" ? "Attested data value" : profile.id === "dd-live-fixed" ? "Due-diligence findings" : "Security audit findings"}

{evidence.deliveryVerified ? "delivery verified" : "delivery unverified"}
- {profile.id === "oracle-auto-accept" && evidence.deliveryVerified ?
{String(audit.preset ?? (reportKind || "attested value"))}{String(audit.value ?? audit.extract ?? "reported")}{String(audit.url ?? "Source recorded in the attestation")}
: findings.length ?
{findings.map((finding, index) =>
{String(finding.severity ?? "info")}
{String(finding.title ?? finding.ruleId ?? finding.id ?? "Finding")}

{String(finding.detail ?? finding.rationale ?? "Attested finding")}

{String(finding.file ?? finding.rule ?? "evidence")}{finding.line !== undefined ? `:${String(finding.line)}` : ""}
)}
:

{evidence.deliveryVerified ? String(record(audit.summary).text ?? "The delivered report was verified and contains no findings.") : "No verified report was returned."}

} -
- -
-
VERIFICATION

Independent acceptance checks

{verificationLabel}
-
- {check("Delivery evidence", evidence.deliveryVerified ? "verified" : "unverified", evidence.deliveryVerified)} - {check("Buyer + seller bundles", evidence.bundlesVerified ? "verified" : "unverified", evidence.bundlesVerified)} - {check("Reconciliation", evidence.reconciled ? "reconciled" : "not reconciled", evidence.reconciled)} - {check(evidence.rulingRequired ? "EvalBot ruling" : "Acceptance policy", evidence.rulingRequired ? evaluationLabel : "not required for fixed price", evidence.rulingRequired ? evidence.rulingAccepted && evidence.rulingValid : true)} -
-
- -
-
ON-CHAIN RECEIPTS

Every DACS artifact and transaction

{transactions.length} records
-
{transactions.map((transaction, index) => { - const txRef = String(transaction.txRef ?? ""); - const anchorRef = String(transaction.address ?? ""); - return
{String(index + 1).padStart(2, "0")}
{String(transaction.name ?? transaction.kind ?? "chain record")}{anchorRef ? `anchor ${compact(anchorRef, 22, 8)}` : (/^0x/.test(txRef) ? "Base Sepolia payment" : "Demos transaction")}
{txRef ? {compact(txRef, 15, 7)} ↗ : existing anchor}
; - })}
-
All anchor addresses
{JSON.stringify(anchors, null, 2)}
-
- -
Raw full-flow JSON
{JSON.stringify({ events, result: value }, null, 2)}
-
- ); -} - -function ProcurementPreviewReport({ - preview, - jobStatus, - error, - profile, -}: { - preview: ProcurementPreview; - jobStatus: ProcurementJob["status"]; - error?: string; - profile: ProcurementProfile; -}) { - const report = preview.delivery.report; - const hasFindingList = Array.isArray(report.findings); - const findings = hasFindingList ? (report.findings as unknown[]).map(record) : []; - const summary = record(report.summary); - const primitiveFields = Object.entries(report).filter(([, value]) => - value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean", - ).slice(0, 8); - const anchorEntries = Object.entries(preview.anchors).filter((entry): entry is [string, string] => typeof entry[1] === "string"); - const finalising = jobStatus === "running"; - const finalisationDetail = finalising - ? "The job remains running until the mandatory buyer and seller bundles verify and reconcile." - : error ?? "The verified delivery remains inspectable, but DACS-5 did not reach terminal acceptance."; - const heading = preview.status === "report-verified-finalising-dacs5" - ? "Verified security report is ready" - : `Verified ${profile.agentName} delivery is ready`; - - return ( -
-
-
VERIFIED DELIVERY · NONTERMINAL

{heading}

- {finalising ? "DACS-5 finalising" : "DACS-5 needs recovery"} -
-
-
Result available nowThe seller-signed delivery and confirmed payment evidence have been verified.
-
{finalising ? "Two bundle copies anchoring" : "Finalisation stopped safely"}{finalisationDetail}
-
- {profile.id === "oracle-auto-accept" ? ( -
- {String(report.preset ?? report.kind ?? "attested value")} - {String(report.value ?? report.extract ?? "reported")} - {String(report.url ?? "Source recorded in the signed delivery attestation")} -
- ) : hasFindingList ? ( - findings.length ? ( -
{findings.map((finding, index) => ( -
- {String(finding.severity ?? "info")} -
{String(finding.title ?? finding.ruleId ?? finding.id ?? "Finding")}

{String(finding.detail ?? finding.rationale ?? "Attested finding")}

{String(finding.file ?? finding.rule ?? "evidence")}{finding.line !== undefined ? `:${String(finding.line)}` : ""}
-
- ))}
- ) :

No findings were returned by the verified security report.

- ) : primitiveFields.length ? ( -
{primitiveFields.map(([key, value]) =>
{key.replaceAll(/[-_]/g, " ")}{String(value ?? "null")}
)}
- ) : ( -

{String(summary.text ?? "The signed deliverable is verified and available in the raw preview below.")}

- )} -
Not settled-and-accepted yetDACS-5 is still mandatory. The Directory will only show final acceptance after the buyer and seller bundles verify and reconcile.
-
Verified preview anchors
{anchorEntries.map(([name, anchor]) =>
{name}{anchor}
)}
-
Raw verified delivery preview
{JSON.stringify(preview, null, 2)}
-
- ); -} - -/** - * The five DACS stages, in the standard's own words (see /how-it-works: - * Identify → Vet → Negotiate → Settle → Verify, "one deal · five receipts"). - * `plain` is the novice explanation shown behind "What's happening here?". - */ -const DACS_STAGES = [ - { - key: "identify", name: "Identify", primitive: "DACS-1", - tagline: "Who offers the service? Signed listings only.", - receipt: "a signed on-chain listing", - plain: "Agents advertise what they offer. In full DACS that's a signed listing anchored on the Demos chain — like a business card that can't be forged. This demo's picker shows the live gateway's agent roster; the Procurement Butler run then resolves and verifies a real signed DACS-1 listing on-chain before dealing.", - }, - { - key: "vet", name: "Vet", primitive: "DACS-2", - tagline: "Check the counterparty before committing.", - receipt: "an anchored verification record", - plain: "Before dealing with a stranger you check who they are. Here the buyer verifies the seller's identity and claims, and writes that check to the chain — so later everyone can see the vet actually happened.", - }, - { - key: "negotiate", name: "Negotiate", primitive: "DACS-3", - tagline: "Fix price, scope and timing — both agents sign.", - receipt: "a dual-signed agreement", - plain: "The two agents agree the job: what will be done, by when, for how much. Both sign the same agreement, so neither can later claim different terms. That signed agreement is anchored before any money moves.", - }, - { - key: "settle", name: "Settle & deliver", primitive: "DACS-4", - tagline: "Pay on the agreed rail; the work arrives with evidence.", - receipt: "a payment transaction + delivery evidence", - plain: "The buyer pays on the agreed payment rail — native DEM on Demos or USDC through x402 on Base Sepolia — and the seller delivers the work. The payment hash and the delivered report are both captured as evidence.", - }, - { - key: "verify", name: "Verify", primitive: "DACS-5", - tagline: "Bind every receipt into one bundle both sides anchor.", - receipt: "a reconciled attestation bundle", - plain: "Finally everything — listing, vet, agreement, payment and delivery — is tied into one signed bundle that both sides anchor. Anyone can re-run the checks later, and honest deals build the seller's public reputation.", - }, -] as const; - -type StageOutput = { - state: "pending" | "active" | "complete" | "warning" | "skipped"; - summary: string; - detail?: string; - /** Chain records produced by this stage, rendered as explorer links. */ - chain?: ProcurementEvent[]; -}; - -function message(value: unknown): string { - if (value && typeof value === "object" && "error" in value) { - const error = (value as { error?: string | { message?: string } }).error; - if (typeof error === "string" && error.trim()) return error; - if (error && typeof error === "object" && error.message) return error.message; - } - return "The Butler could not complete that step."; -} - -export default function TryDacs() { - const [agents, setAgents] = useState([]); - const [profiles, setProfiles] = useState([]); - const [selectedProfileId, setSelectedProfileId] = useState(null); - const [selectedPaymentRail, setSelectedPaymentRail] = useState("pay-dem"); - const [goal, setGoal] = useState(""); - const [plan, setPlan] = useState(null); - const [inputValue, setInputValue] = useState>({}); - const [gatewayFieldErrors, setGatewayFieldErrors] = useState({}); - const [submittedSummary, setSubmittedSummary] = useState([]); - const [phase, setPhase] = useState<"idle" | "planning" | "ready" | "running" | "done" | "error">("idle"); - const [result, setResult] = useState(); - const [procurementJob, setProcurementJob] = useState(null); - const [error, setError] = useState(""); - const [runStartedAt, setRunStartedAt] = useState(null); - const [elapsedMs, setElapsedMs] = useState(0); - const [receipt, setReceipt] = useState(null); - const [receiptPolling, setReceiptPolling] = useState(false); - const [receiptMessage, setReceiptMessage] = useState(""); - const runAbort = useRef(null); - const receiptAbort = useRef(null); - const procurementJobRef = useRef(null); - // One idempotency key per intentional procurement run, reused across retries - // of that run so the gateway returns the existing job instead of creating a - // second paid one. Cleared when a new intent begins (new agent, edited input, - // Load example) or on success. - const procurementRunId = useRef(null); - - useEffect(() => { - fetch(`${BUTLER}/demo/procurement/options`) - .then((res) => res.ok ? res.json() : Promise.reject(new Error("procurement options unavailable"))) - .then((body: unknown) => { - const live = parseProcurementProfiles(body) - .filter((profile) => profile.executable && LIVE_PROCUREMENT_PROFILE_IDS.includes(profile.id)); - if (live.length !== 3 || new Set(live.map((profile) => profile.id)).size !== 3) { - throw new Error("the three production procurement profiles are not all executable"); - } - setProfiles(live); - setAgents(live.map((profile) => procurementProfileCard(profile, defaultPaymentRail(profile)))); - }) - .catch((cause: unknown) => setError(cause instanceof ButlerContractError - ? cause.message - : "The live procurement demos are temporarily unavailable.")); - }, []); - - useEffect(() => { - if (!runStartedAt || (phase !== "running" && !receiptPolling)) return; - const update = () => setElapsedMs(Date.now() - runStartedAt); - update(); - const timer = setInterval(update, 100); - return () => clearInterval(timer); - }, [phase, receiptPolling, runStartedAt]); - - useEffect(() => { procurementJobRef.current = procurementJob; }, [procurementJob]); - - // The persisted procurement run (idempotency key + input + job id). Written - // BEFORE the POST is dispatched so a reload/crash can never lose the key of - // a purchase the gateway may have started; kept until the job reaches a - // browser-verified safe terminal state. - const [storedRun, setStoredRun] = useState(null); - const updateStoredRun = useCallback((run: StoredProcurementRun | null): boolean => { - const ok = writeStoredRun(run); - // Reflect what storage actually holds, not what we hoped to write. - setStoredRun(ok ? run : readStoredRun()); - return ok; - }, []); - /** Ownership-conditional delete: never removes another tab's record. */ - const releaseStoredRun = useCallback((runId: string | null) => { - releaseStoredRunIfOwned(runId); - setStoredRun(readStoredRun()); - }, []); - useEffect(() => { setStoredRun(readStoredRun()); }, []); - // Keep this tab's view of the record in sync when ANOTHER tab writes or - // clears it (storage events fire only in non-originating tabs). - useEffect(() => { - const onStorage = (event: StorageEvent) => { - if (event.key === PROCUREMENT_RUN_KEY || event.key === null) setStoredRun(readStoredRun()); - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - - useEffect(() => () => { - runAbort.current?.abort(); - receiptAbort.current?.abort(); - }, []); - - const selectedProfile = useMemo(() => profiles.find((profile) => profile.id === selectedProfileId), [profiles, selectedProfileId]); - const selected = useMemo(() => selectedProfile - ? procurementProfileCard(selectedProfile, selectedPaymentRail) - : agents.find((agent) => agent.name === plan?.butler.selectedAgent), - [agents, plan, selectedPaymentRail, selectedProfile]); - const selectedRailReadiness = selectedProfile?.railReadiness[selectedPaymentRail]; - const execution = record(record(result).execution); - const specialistDurationMs = typeof execution.durationMs === "number" ? execution.durationMs : undefined; - const receiptElapsedMs = receipt?.createdAt - ? Math.max(0, (receipt.status === "confirmed" || receipt.status === "failed" ? Date.parse(receipt.updatedAt ?? receipt.createdAt) : Date.now()) - Date.parse(receipt.createdAt)) - : 0; - const procurementAccepted = selectedProfile !== undefined && result !== undefined - ? procurementEvidence(result, selectedProfile.mode).overallAccepted - : false; - const validateInput = useCallback((agentName: string, value: Record): FieldErrors => { - if (hasBuiltinForm(agentName)) return validateAgentInput(agentName, value); - const schema = selected ? parseAgentFieldSchema(selected) : null; - return schema ? validateSchemaInput(schema, value) : {}; - }, [selected]); - const localErrors = useMemo( - () => plan ? validateInput(plan.butler.selectedAgent, inputValue) : {}, - [plan, inputValue, validateInput], - ); - const inputIsValid = Object.keys(localErrors).length === 0 && (selectedRailReadiness?.executable ?? true); - // Verification completes only on evidence: a confirmed receipt (or no - // receipt advertised at all). A synchronous broadcast-only attestation has - // no status URL to poll, so its confirmation is UNKNOWN in this browser — - // the Verify phase stays visibly pending rather than claiming completion. - const verificationComplete = phase === "done" && procurementAccepted; - const resultPayload = result; - const resultFields = Object.keys(record(resultPayload)); - const isProcurementSel = selectedProfileId !== null; - const procEvents = procurementJob?.events ?? []; - const procurementPreview = procurementJob?.preview; - const procurementWaiting = phase === "running" && procurementJob?.queue?.status === "waiting"; - // Fail-closed staging: a terminal "failed" (or unknown-phase) event attaches - // to the stage the run had actually reached and never advances progress. - const { byStage: eventsByStage, progress: procStage } = stageEvents(procEvents); - - // Live procurement run: each DACS stage reports the gateway's own events - // (and their chain records) as they arrive. - function procurementStageOutput(stageIdx: number): StageOutput { - const events = eventsByStage[stageIdx]!; - const latest = events[events.length - 1]; - const chain = events.filter((event) => event.txRef || event.anchorRef); - const jobComplete = procurementJob?.status === "complete"; - const jobFailed = procurementJob?.status === "failed" || phase === "error"; - if (stageIdx === 3 && procurementPreview) { - return { - state: "complete", - summary: "Payment evidence and seller delivery verified", - detail: "The verified result is available; two-sided DACS-5 reconciliation continues separately", - chain, - }; - } - if (stageIdx === 4 && procurementPreview && procurementJob?.status === "running") { - return { - state: "active", - summary: "Verified delivery ready; two DACS-5 copies are finalising", - detail: "Completion remains pending until both bundles reconcile", - chain, - }; - } - if (stageIdx === 4 && jobComplete) { - return { - state: procurementAccepted ? "complete" : "warning", - summary: procurementAccepted ? "Full evidence bundle accepted & reconciled" : "Verification incomplete", - detail: latest?.label, - chain, - }; - } - if (procStage > stageIdx || jobComplete) { - return { state: "complete", summary: latest?.label ?? "Completed", detail: events.length > 1 ? `${events.length} steps recorded` : undefined, chain }; - } - if (procStage === stageIdx) { - return jobFailed - ? { state: "warning", summary: latest?.label ?? "Stopped safely", detail: error || undefined, chain } - : { state: "active", summary: latest?.label ?? "In progress", detail: `${elapsedLabel(elapsedMs)} elapsed`, chain }; - } - return { state: "pending", summary: "Waiting for the earlier stages" }; - } - - const identifyOut: StageOutput = !agents.length - ? { state: "active", summary: "Reading the live procurement profiles…" } - : !plan - ? { state: "active", summary: `${agents.length} live procurement routes — pick one`, detail: agents.map((agent) => agent.label).join(", ") } - : isProcurementSel - ? { state: "pending", summary: `${selected?.label ?? "The seller"}'s signed DACS-1 listing is resolved and verified when the run starts` } - : { state: "skipped", summary: `${plan.butler.label} picked from the gateway roster — no DACS-1 listing involved`, detail: "This picker is the gateway's published roster, not a signed listing. A real DACS-1 listing is only resolved and verified during the full Procurement run." }; - const vetOut: StageOutput = !plan - ? { state: "pending", summary: "Waiting for an agent choice" } - : isProcurementSel - ? { state: "pending", summary: "Runs live during the purchase — the vet record is anchored on-chain" } - : { state: "skipped", summary: "Skipped in this demo", detail: "The Butler already trusts this in-network specialist. Run the Procurement Butler to watch a real counterparty vet get anchored as a DACS-2 record." }; - const negotiateOut: StageOutput = !plan - ? { state: "pending", summary: "Waiting for an agent choice" } - : isProcurementSel - ? { state: "pending", summary: "Runs live during the purchase — both agents sign the agreement" } - : { state: "skipped", summary: "Skipped — this demo is free", detail: "There is nothing to price here. The Procurement Butler negotiates a real RFQ and both agents sign the DACS-3 agreement before payment." }; - const settleOut: StageOutput = phase === "running" - ? { state: "active", summary: `Live procurement running · ${elapsedLabel(elapsedMs)}`, detail: `The gateway confirms the signed agreement before broadcasting the real ${paymentRailLabel(selectedPaymentRail)} payment.` } - : phase === "done" - ? { state: "complete", summary: specialistDurationMs === undefined ? "Result delivered" : `Result delivered in ${elapsedLabel(specialistDurationMs)}`, detail: resultFields.length ? `Result fields: ${resultFields.join(", ")}` : "The complete result is shown below." } - : phase === "error" - ? { state: "warning", summary: "Execution stopped safely", detail: error } - : plan && inputIsValid - ? { state: "pending", summary: `A real ${paymentRailLabel(selectedPaymentRail)} payment happens here when you press Run` } - : plan - ? { state: "active", summary: "Waiting for required fields", detail: `${Object.keys(localErrors).length} field${Object.keys(localErrors).length === 1 ? " needs" : "s need"} attention in the form` } - : { state: "pending", summary: "Waiting for job details" }; - // A specialist's output attestation is real evidence, but it is a - // single-sided anchor — NOT the two-party DACS-5 bundle this stage names — - // so the stage never earns a completion tick outside the full purchase. - const verifyOut: StageOutput = receipt - ? { - state: "skipped", - summary: `Single-sided output anchor ${receipt.status} — not a DACS-5 bundle${!receipt.statusUrl && receipt.status !== "confirmed" && receipt.status !== "failed" ? " · confirmation unknown (anchoring continues on the gateway)" : ""}`, - detail: `${receipt.txRef ? `Transaction ${compact(receipt.txRef, 14, 7)} — inspect it in Chain activity below.` : `Anchor ${compact(receipt.anchorAddress, 18, 8)}.`}${receipt.status === "failed" ? " Anchoring failed safely — use the receipt panel below to retry." : ""} The reconciled two-party DACS-5 bundle is only produced by the full Procurement run.`, - } - : phase === "done" - ? { state: "skipped", summary: "No DACS-5 bundle — this demo returns the result directly", detail: "This gateway did not advertise a separate live receipt for this agent. The reconciled DACS-5 bundle is only produced by the full Procurement run." } - : { state: "pending", summary: "Waiting for delivery" }; - - // A live/finished procurement job reports the real gateway events per stage; - // everything else (specialists, and procurement before the run) uses the - // honest static mapping — including visibly-skipped stages. - const stageOutputs: StageOutput[] = isProcurementSel && procEvents.length - ? DACS_STAGES.map((_, stageIdx) => procurementStageOutput(stageIdx)) - : [identifyOut, vetOut, negotiateOut, settleOut, verifyOut]; - - // Every chain record seen this run (procurement events + the specialist - // receipt), so all transactions stay visible in one place with explorer links. - const chainActivity: Array<{ label: string; txRef?: string; anchorRef?: string }> = [ - ...procEvents.filter((event) => event.txRef || event.anchorRef).map((event) => ({ label: event.label, txRef: event.txRef, anchorRef: event.anchorRef })), - ...(receipt ? [{ label: "Output attestation anchored", txRef: receipt.txRef, anchorRef: receipt.anchorAddress }] : []), - ]; - const chainTxCount = chainActivity.filter((row) => row.txRef).length; - - async function watchReceipt(initial: OutputReceipt) { - // The synchronous LIVE-ANCHOR attestation has no status URL: there is - // nothing to poll — the anchor status shown is already the final report - // from the run response. - const statusUrl = initial.statusUrl; - if (!statusUrl) return; - receiptAbort.current?.abort(); - const controller = new AbortController(); - receiptAbort.current = controller; - setReceiptPolling(true); setReceiptMessage(""); - let current = initial; - const deadline = Date.now() + RECEIPT_WATCH_TIMEOUT_MS; - try { - while (current.status !== "confirmed" && current.status !== "failed") { - if (Date.now() >= deadline) { - setReceiptMessage("Receipt confirmation is taking longer than two minutes. The result is safe; you can check again without rerunning the agent."); - return; - } - await waitWithSignal(Math.min(1_500, deadline - Date.now()), controller.signal); - const { response, body } = await fetchJsonBeforeDeadline( - new URL(current.statusUrl ?? statusUrl, `${BUTLER}/`), - { signal: controller.signal }, - deadline, - fetch, - "Receipt status checking exceeded two minutes.", - ); - if (!response.ok) throw new Error(message(body)); - current = parseReceiptEnvelope(body); - setReceipt(current); - } - if (current.status === "failed") setReceiptMessage(current.error ?? "Receipt anchoring failed safely. Retry will reuse the same receipt and wallet queue."); - } catch (cause) { - if ((cause as Error).name !== "AbortError") setReceiptMessage((cause as Error).message); - } finally { - if (receiptAbort.current === controller) { - receiptAbort.current = null; - setReceiptPolling(false); - } - } - } - - async function retryReceipt() { - if (!receipt?.statusUrl) return; - try { - let next = receipt; - if (receipt.status === "failed") { - const { response, body } = await fetchJsonWithTimeout( - new URL(`${receipt.statusUrl}/retry`, `${BUTLER}/`), - { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, - 15_000, - "The receipt retry request timed out.", - ); - if (!response.ok) throw new Error(message(body)); - next = parseReceiptEnvelope(body); - setReceipt(next); - } - void watchReceipt(next); - } catch (cause) { - setReceiptMessage((cause as Error).message); - } - } - - function cancelRun() { - runAbort.current?.abort(); - } - - function cancelReceiptWatch() { - receiptAbort.current?.abort(); - setReceiptPolling(false); - setReceiptMessage("Stopped checking in this browser. The gateway will continue nonce-safe anchoring in the background."); - } - - async function runAgent() { - if (!plan) return; - let parsed: Record; - try { parsed = parseAgentInput(inputValue); } - catch (cause) { - setError((cause as Error).message); - return; - } - // Local validation is advisory feedback; a hard local failure blocks the - // obviously-broken submissions, the gateway remains authoritative. - const advisory = validateInput(plan.butler.selectedAgent, parsed); - if (Object.keys(advisory).length > 0) { - setError("Some fields need attention before this can run — see the highlighted inputs."); - return; - } - // A new procurement run must not overwrite the persisted record of an - // earlier, unreconciled one — that record may be the only handle on a - // paid job. Retrying the SAME run (matching key) passes through. - if (isProcurementSel && storedRun && storedRun.runId !== procurementRunId.current) { - setError("An earlier procurement run from this browser is still on record. Use “Check & resume” in the banner above (or dismiss it once reconciled) before starting a new purchase."); - return; - } - runAbort.current?.abort(); receiptAbort.current?.abort(); - const controller = new AbortController(); - runAbort.current = controller; - setPhase("running"); setError(""); setResult(undefined); setReceipt(null); setReceiptMessage(""); - // Clear any prior job before a fresh start so a failed start can never - // offer resuming a stale job (ref set synchronously: the catch below may - // run before the state-sync effect). - setProcurementJob(null); procurementJobRef.current = null; - setGatewayFieldErrors({}); - setSubmittedSummary(summarizeAgentInput(plan.butler.selectedAgent, parsed)); - setRunStartedAt(Date.now()); setElapsedMs(0); - // A 4xx whose details name specific fields returns the user to the form - // with the gateway's own words beside the inputs (authoritative). - const fieldMappedRejection = (body: unknown): boolean => { - const envelope = record(record(body).error); - const gatewayMessage = typeof envelope.message === "string" ? envelope.message : ""; - const details = Array.isArray(envelope.details) ? envelope.details.filter((line): line is string => typeof line === "string") : undefined; - if (!gatewayMessage) return false; - const mapped = mapGatewayErrors(gatewayMessage, details, flattenInputKeys(parsed)); - if (Object.keys(mapped.byField).length === 0) return false; - setGatewayFieldErrors(mapped.byField); - setError([gatewayMessage, ...mapped.global].filter(Boolean).join(" — ")); - setPhase("ready"); - return true; - }; - try { - if (isProcurementSel && selectedProfile) { - const deadline = Date.now() + 12 * 60_000; - // The Directory is the discovery surface. Pass its verified listing - // pointer to the Butler; the gateway independently dereferences and - // verifies the signed DACS-1 artifact before negotiation. - const request: Record = { profileId: selectedProfile.id, ...parsed, paymentRail: selectedPaymentRail }; - if (selectedProfile.id === "security-audit-rfq") try { - const { response: catalog, body } = await fetchJsonBeforeDeadline<{ listings?: Array<{ listingId?: string; anchor?: { locator?: string }; offering?: { title?: string; negotiation?: string[] } }> }>(`/api/dacs/listings?rail=${encodeURIComponent(selectedPaymentRail)}&limit=100`, { signal: controller.signal }, deadline); - if (catalog.ok) { - const auditor = body.listings?.find((item) => item.listingId === "audit-negotiator" && item.offering?.negotiation?.includes("rfq") && /auditor/i.test(item.offering?.title ?? "")); - const ref = auditor?.anchor?.locator; - if (ref) request.auditorListingRef = ref; - } - } catch { /* gateway will derive and verify the configured Auditor slot */ } - // One key per intentional run; a retry of this same run reuses it so - // the gateway dedupes to the existing job (never a second payment). - procurementRunId.current ??= crypto.randomUUID(); - // Read-record → write-record → POST runs under a cross-tab exclusive - // lock, with the record RE-READ from storage inside the section: two - // tabs can otherwise interleave, the loser overwriting the winner's - // record and then deleting it on its own 4xx. - const started = await withExclusiveProcurementLock(browserLocks(), controller.signal, async (): Promise => { - const existing = readStoredRun(); - if (existing && existing.runId !== procurementRunId.current) { - setPhase("ready"); - setError("Another procurement run's recovery record is active (possibly from another tab). Use “Check & resume” in the banner — or dismiss it once reconciled — before starting a new purchase."); - return null; - } - // Persist the key + input BEFORE dispatch: a reload/crash between - // the POST and the job-id response must not lose the only handle on - // a purchase the gateway may have started. Restored on mount. - const runRecord: StoredProcurementRun = { runId: procurementRunId.current!, goal, input: request, startedAt: new Date().toISOString() }; - if (!updateStoredRun(runRecord)) { - // No durable recovery record → a reload would lose the only - // handle on the purchase. Refuse to send the paid request at all. - setPhase("ready"); - setError("This browser cannot durably save the purchase-recovery record (storage is blocked or full), so the paid request was NOT sent. Free up site storage or use a different browser profile, then run again."); - return null; - } - const { response: start, body: startBody } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": runRecord.runId }, - body: JSON.stringify(request), signal: controller.signal, - }, deadline); - if (!start.ok) { - // A 4xx is a verified pre-job rejection (nothing was created), so - // THIS run's record has nothing to protect — release it only if - // it is still ours. A 5xx/opaque failure keeps it: the job may - // exist, and the key is the only handle. - if (start.status >= 400 && start.status < 500) releaseStoredRun(runRecord.runId); - if (fieldMappedRejection(startBody)) return "handled"; - throw new Error(message(startBody)); - } - const job = parseProcurementJob(startBody); - procurementJobRef.current = job; // synchronous — the catch relies on it - updateStoredRun({ ...runRecord, jobId: job.id }); - setProcurementJob(job); - return job; - }).catch((cause: unknown) => { - if (cause instanceof ProcurementLockUnavailableError) { - setPhase("ready"); - setError(`${cause.message} Use a current version of Chrome, Edge, Firefox or Safari — the paid request was NOT sent.`); - return null; - } - throw cause; - }); - if (started === null || started === "handled") return; - await followProcurementJob(started, controller, deadline); - return; - } - const { response: res, body } = await fetchJsonWithTimeout(`${BUTLER}/demo/butler`, { - method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ goal, agent: plan.butler.selectedAgent, input: parsed }), - signal: controller.signal, - }, AGENT_TIMEOUT_MS, AGENT_TIMEOUT_MESSAGE); - if (!res.ok) { - if (fieldMappedRejection(body)) return; - throw new Error(message(body)); - } - const completed = parseButlerRun(body); - setResult(completed); setPhase("done"); - if (completed.outputAttestation) { - setReceipt(completed.outputAttestation); - void watchReceipt(completed.outputAttestation); - } - } catch (cause) { - const cancelled = (cause as Error).name === "AbortError"; - const isProcurement = isProcurementSel; - const haveJob = procurementJobRef.current !== null; - // The retry-reuses-the-key reassurance only holds while the key is - // retained — a terminal failed job is explained by the recovery box - // instead, so its gateway reason is shown verbatim. - const failedJob = (procurementJobRef.current as ProcurementJob | null)?.status === "failed"; - setError(cancelled - ? isProcurement && haveJob - ? "Stopped watching in this browser. The gateway is still completing this procurement job — it was NOT cancelled, and any payment it makes still happens. Resume below to keep following the same job; no second purchase will be started." - : "Run cancelled in this browser. No result was accepted; you can retry the same bounded job." - : isProcurement && !failedJob - ? `${(cause as Error).message} — Retrying reuses the same idempotency key, so the gateway resumes the existing job instead of starting a second paid purchase.` - : (cause as Error).message); - setPhase("error"); - } finally { - if (runAbort.current === controller) runAbort.current = null; - } - } - - /** - * Follow an already-started procurement job to completion. Used by the - * initial run and by "Resume status" after the user stops watching — the - * job id is retained so resuming NEVER creates a second job or payment. - */ - async function followProcurementJob(initial: ProcurementJob, controller: AbortController, deadline: number) { - let current = initial; - while (current.status === "running" && Date.now() < deadline) { - await waitWithSignal(Math.min(2_000, Math.max(0, deadline - Date.now())), controller.signal); - const { response: poll, body: pollBody } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement/${encodeURIComponent(current.id)}`, { signal: controller.signal }, deadline); - if (!poll.ok) throw new Error(message(pollBody)); - current = parseProcurementJob(pollBody); - // Keep the synchronous ref aligned with the rendered state. The outer - // catch uses it before React effects run to distinguish a terminal - // gateway failure from a transient watcher error. - procurementJobRef.current = current; - setProcurementJob(current); - } - if (current.status === "failed") { - // Only the gateway's explicit failedBeforePayment=true proves no money - // moved — then (and only then) a fresh purchase is safe, so the key and - // persisted record are released (if the record is still this run's). - // Anything else (flag false or absent) may have paid: keep both, so the - // job stays referenced for the gateway's operator recovery path and no - // new purchase is offered. - if (current.failedBeforePayment === true) { - const ownedRunId = procurementRunId.current; - procurementRunId.current = null; - releaseStoredRun(ownedRunId); - } - throw new Error(current.error ?? "the full procurement flow failed safely"); - } - if (current.status !== "complete") throw new Error("the full procurement flow is still running; use Resume status to keep following it"); - setResult(current.result); setPhase("done"); - const ownedRunId = procurementRunId.current; - procurementRunId.current = null; - releaseStoredRun(ownedRunId); - } - - /** Resume following the existing procurement job (never a new purchase). */ - async function resumeProcurement() { - const job = procurementJobRef.current; - if (!job) return; - runAbort.current?.abort(); - const controller = new AbortController(); - runAbort.current = controller; - setPhase("running"); setError(""); - setRunStartedAt(Date.now()); setElapsedMs(0); - try { - await followProcurementJob(job, controller, Date.now() + 12 * 60_000); - } catch (cause) { - const cancelled = (cause as Error).name === "AbortError"; - setError(cancelled - ? "Stopped watching in this browser. The gateway is still completing this procurement job — resume below to keep following it; no second purchase will be started." - : (cause as Error).message); - setPhase("error"); - } finally { - if (runAbort.current === controller) runAbort.current = null; - } - } - - /** - * 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. - */ - async function resumeStoredRun() { - const stored = storedRun; - if (!stored) return; - runAbort.current?.abort(); receiptAbort.current?.abort(); - const controller = new AbortController(); - runAbort.current = controller; - procurementRunId.current = stored.runId; - setGoal(stored.goal); - const storedProfileId = typeof stored.input.profileId === "string" ? stored.input.profileId : "security-audit-rfq"; - const storedAgent = PROFILE_AGENT[storedProfileId] ?? PROFILE_AGENT["security-audit-rfq"]!; - const storedPaymentRail: PaymentRail = stored.input.paymentRail === "pay-x402" ? "pay-x402" : "pay-dem"; - setSelectedProfileId(storedProfileId); - setSelectedPaymentRail(storedPaymentRail); - setPlan({ - butler: { - selectedAgent: storedAgent.name, - label: storedAgent.label, - selectionEngine: "restored run", - rationale: "Reconnecting to the procurement run this browser dispatched earlier — same idempotency key, so no second purchase can start.", - alternatives: [], - }, - proposedInput: stored.input, - inputNote: "Restored from this browser's persisted run record.", - }); - const { profileId: _profileId, auditorListingRef: _auditorListingRef, ...storedFormInput } = stored.input; - setInputValue(storedFormInput); - setResult(undefined); setReceipt(null); setReceiptMessage(""); setGatewayFieldErrors({}); - setProcurementJob(null); procurementJobRef.current = null; - setSubmittedSummary(summarizeAgentInput(storedAgent.name, storedFormInput)); - setPhase("running"); setError(""); - setRunStartedAt(Date.now()); setElapsedMs(0); - const deadline = Date.now() + 12 * 60_000; - try { - let job: ProcurementJob; - if (stored.jobId) { - const { response, body } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement/${encodeURIComponent(stored.jobId)}`, { signal: controller.signal }, deadline); - 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 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 - // must never act on (or overwrite) a record it no longer owns. - job = await withExclusiveProcurementLock(browserLocks(), controller.signal, async () => { - const decision = resumeDispatchDecision(stored, readStoredRun()); - if (decision.action === "abort-stale") throw new StaleResumeRecordError(); - 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); - if (!response.ok) throw new Error(message(body)); - return parseProcurementJob(body); - } - const { response, body } = await fetchJsonBeforeDeadline(`${BUTLER}/demo/procurement`, { - method: "POST", - headers: { "content-type": "application/json", "Idempotency-Key": stored.runId }, - body: JSON.stringify(stored.input), signal: controller.signal, - }, deadline); - if (!response.ok) { - if (response.status >= 400 && response.status < 500) releaseStoredRun(stored.runId); - throw new Error(message(body)); - } - const parsedJob = parseProcurementJob(body); - updateStoredRun({ ...stored, jobId: parsedJob.id }); - return parsedJob; - }); - } - procurementJobRef.current = job; - 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. - procurementRunId.current = null; - setPlan(null); setPhase("idle"); - setError(cause.message); - return; - } - const cancelled = (cause as Error).name === "AbortError"; - const failedJob = (procurementJobRef.current as ProcurementJob | null)?.status === "failed"; - setError(cancelled - ? "Stopped watching in this browser. Any live gateway job continues — resume again to keep following it." - : cause instanceof ProcurementLockUnavailableError - ? `${cause.message} Use a current version of Chrome, Edge, Firefox or Safari — no request was sent.` - : failedJob - ? (cause as Error).message - : `${(cause as Error).message} — Resuming again reuses the same idempotency key, so no second purchase can start.`); - setPhase("error"); - } finally { - if (runAbort.current === controller) runAbort.current = null; - } - } - - function selectAgent(agent: AgentCard) { - runAbort.current?.abort(); receiptAbort.current?.abort(); - setGoal(agent.exampleGoal); - const profile = profiles.find((candidate) => procurementProfileCard(candidate, defaultPaymentRail(candidate)).name === agent.name); - const paymentRail = profile ? defaultPaymentRail(profile) : "pay-dem"; - const railAgent = profile ? procurementProfileCard(profile, paymentRail) : agent; - setSelectedProfileId(profile?.id ?? null); - setSelectedPaymentRail(paymentRail); - setPlan({ - butler: { - selectedAgent: agent.name, - label: agent.label, - selectionEngine: "user-selected test", - rationale: `You selected ${agent.label}. Fill in the job below (or load its example), and I will supervise the run and show you the evidence it returns.`, - alternatives: [], - }, - proposedInput: railAgent.exampleInput, - inputNote: "Your values are submitted unchanged to the live gateway, which applies specialist validation before execution.", - }); - // Switching agents resets to that agent's blank form. Examples are never - // submitted silently — "Load example" below is the only way they enter. - // Schema-driven agents seed select/checkbox defaults so the form's display - // matches submitted state. - const schema = hasBuiltinForm(railAgent.name) ? null : parseAgentFieldSchema(railAgent); - setInputValue(schema ? initialSchemaInput(schema) : initialAgentInput(railAgent.name, paymentRail)); - procurementRunId.current = null; - setGatewayFieldErrors({}); setSubmittedSummary([]); - setResult(undefined); setProcurementJob(null); setReceipt(null); setReceiptMessage(""); setPhase("ready"); setError(""); - window.scrollTo({ top: 0, behavior: "smooth" }); - } - - function selectPaymentRail(rail: PaymentRail) { - if (!selectedProfile || rail === selectedPaymentRail || !selectedProfile.railReadiness[rail]?.executable) return; - const railAgent = procurementProfileCard(selectedProfile, rail); - setSelectedPaymentRail(rail); - setPlan((current) => current ? { ...current, proposedInput: railAgent.exampleInput } : current); - const schema = hasBuiltinForm(railAgent.name) ? null : parseAgentFieldSchema(railAgent); - setInputValue(schema ? initialSchemaInput(schema) : initialAgentInput(railAgent.name, rail)); - procurementRunId.current = null; - setGatewayFieldErrors({}); setSubmittedSummary([]); setError(""); - } - - function loadExample() { - if (!selected) return; - procurementRunId.current = null; - setInputValue(JSON.parse(JSON.stringify(selected.exampleInput)) as Record); - setGatewayFieldErrors({}); - setError(""); - } - - function editInput(next: Record) { - setInputValue(next); - // Edited input is a new intent: a fresh idempotency key so a retry can't - // dedupe to a job that ran the previous payload. - procurementRunId.current = null; - // Stale gateway verdicts don't apply to edited input. - if (Object.keys(gatewayFieldErrors).length) setGatewayFieldErrors({}); - } - - return ( -
-
-
- LIVE DACS WALKTHROUGH -

Buy agent work.
Watch the deal.

-
-

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.

-
- - {/* 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. */} - {storedRun && phase !== "running" && !(procurementJob && storedRun.jobId && procurementJob.id === storedRun.jobId) && ( -
-
- 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.

-
-
- - -
-
- )} - -
-
-
B
DACS Butler{agents.length ? `${agents.length} procurement routes live · gateway connected` : "Connecting to the procurement gateway…"}
-
-
Butler

Welcome. Choose how you want to buy agent work. I’ll run the complete live DACS deal and explain the evidence produced at every phase.

- {goal && phase !== "idle" &&
You

{goal}

} - {plan &&
Butler

{plan.butler.label} is ready. {plan.butler.rationale}

{plan.butler.selectionEngine}
} - {phase === "done" &&
Butler

The specialist finished. Its result is ready now; the separate receipt status below shows any on-chain anchoring still completing in the background.

} - {error &&
Stopped safely

{error}

} -
- - {phase === "idle" || (phase === "error" && !plan) ? ( -
-
Choose a live procurement{agents.length} available
-
{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 ; - })}
-
- ) : phase === "error" && plan ? ( -
- {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" ? ( - <> -
Procurement failed after payment may have moved — do not re-purchaseJob {procurementJob.id} is preserved on the gateway with its payment evidence; the gateway cannot confirm the failure happened before payment, so starting a new run could pay twice. A gateway operator can recover the delivery for the existing job without another payment.
-
- - ) : isProcurementSel && procurementJob ? ( - <> -
Watching stopped — the procurement job is still live on the gatewayJob {procurementJob.id} was not cancelled; resuming follows the same job and never starts a second purchase.
-
- - ) : 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 &&
-
Choose payment railThis changes the real asset and settlement network.
-
{selectedProfile.paymentRails.map((rail) => { - const readiness = selectedProfile.railReadiness[rail]; - const ready = readiness?.executable === true; - return ; - })}
- {selectedRailReadiness?.railGovernance &&
- {selectedRailReadiness.railGovernance.conformantAuthority ? "Canonical rail authority" : "Operator-provisional rail authority"}{selectedRailReadiness.railGovernance.conformantAuthority ? "Authority conforms to the current DACS rail registry rules." : "Usable in this live demo while the standardised rail authority is being agreed."} - Read disclosure ↗ -
} -
- Hosted demo buyer · automatic executionThe gateway runs every phase server-side with its demo identity and wallets. It does not pause for confirmations or accept your own DACS identity or payment signer. -
-
} - -
- - - -
-
- ) : phase === "running" && isProcurementSel ? ( -
{procurementWaiting ? `QUEUED · POSITION ${procurementJob?.queue?.position ?? "…"} · ${elapsedLabel(elapsedMs)}` : procurementPreview ? `DELIVERY VERIFIED · DACS-5 FINALISING · ${elapsedLabel(elapsedMs)}` : `FULL DACS FLOW · ${paymentRailLabel(selectedPaymentRail)} · ${elapsedLabel(elapsedMs)}`}
{procurementWaiting ? "Your purchase is safely queued behind the active buyer-wallet deal. No payment has started yet." : procurementPreview ? "The result is available below. Keep this page open while both mandatory bundle copies reconcile." : "Keep this page open — settlement and chain confirmations appear here live."}
{submittedSummary.length > 0 &&
SUBMITTED
    {submittedSummary.map((line, index) =>
  • {line}
  • )}
}
{(procurementJob?.events ?? []).map((event, index) =>
{index === (procurementJob?.events.length ?? 0) - 1 ? "·" : "✓"}{event.label}{new Date(event.at).toLocaleTimeString()}{event.txRef ? ` · tx ${compact(event.txRef, 12, 6)}` : ""}
)}
- ) : phase === "running" ?
Specialist is workingAgent execution · {elapsedLabel(elapsedMs)} elapsed · 2-minute deadline{submittedSummary.length > 0 &&
SUBMITTED
    {submittedSummary.map((line, index) =>
  • {line}
  • )}
}
- : null} -
- - -
- - {procurementPreview && selectedProfile && result === undefined && ( -
- -
- )} - - {result !== undefined && selectedProfile &&
{procurementAccepted ? "verified" : "verification incomplete"}

{selected?.label ?? "Agent"} result

{specialistDurationMs !== undefined && Specialist completed in {elapsedLabel(specialistDurationMs)} · {paymentRailLabel(selectedPaymentRail)}}
} - -
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 ; - })}
-
- ); -} diff --git a/reference-implementations/dacs-directory/src/components/try-chat-script.ts b/reference-implementations/dacs-directory/src/components/try-chat-script.ts deleted file mode 100644 index bfd9638..0000000 --- a/reference-implementations/dacs-directory/src/components/try-chat-script.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { ProcurementEvent } from "./try-dacs-contract.js"; - -/** - * Pure conversation model for the two-agent /try-chat proposal. - * - * The gateway emits a flat list of procurement events. This module turns each - * event into a chat turn attributed to the agent that produced it — Butler - * (the buyer, left) or the Auditor (the seller, right) — plus on-chain - * "receipt" turns and the independent EvalBot referee. Every turn keeps the - * gateway's exact `raw` label so the friendly text never becomes theatre: the - * UI can always reveal the original evidence string. - */ - -export type Speaker = "butler" | "seller" | "chain" | "referee"; - -/** DACS stage index 0..4 (Identify, Vet, Negotiate, Settle & deliver, Verify). */ -export type StageIndex = 0 | 1 | 2 | 3 | 4; - -export type ConversationTurn = { - /** Stable key = source event index. */ - id: number; - speaker: Speaker; - stage: StageIndex; - /** Plain-language line shown in the bubble. */ - text: string; - /** The gateway's exact event label — the underlying evidence. */ - raw: string; - /** "say" = spoken line · "anchor" = on-chain receipt · "pay" = DEM payment. */ - kind: "say" | "anchor" | "pay"; - txRef?: string; - anchorRef?: string; -}; - -export const SPEAKERS: Record = { - butler: { name: "Butler", role: "the buyer's agent", side: "left", avatar: "B" }, - seller: { name: "Auditor", role: "the seller's agent", side: "right", avatar: "A" }, - chain: { name: "Demos chain", role: "public ledger", side: "center", avatar: "D" }, - referee: { name: "EvalBot", role: "independent judge", side: "center", avatar: "E" }, -}; - -// Stage names match demos.network: Identify · Vet · Negotiate · Settle · Verify. -export const STAGES: { name: string; primitive: string; blurb: string }[] = [ - { name: "Identify", primitive: "DACS-1", blurb: "A signed, on-chain listing." }, - { name: "Vet", primitive: "DACS-2", blurb: "Check the counterparty." }, - { name: "Negotiate", primitive: "DACS-3", blurb: "Both sides sign the terms." }, - { name: "Settle", primitive: "DACS-4", blurb: "Payment and delivery, with evidence." }, - { name: "Verify", primitive: "DACS-5", blurb: "One bundle anyone can re-check." }, -]; - -/** Gateway phase → DACS stage. Failures/unknowns fall back to the caller's running stage. */ -const PHASE_STAGE: Record = { - queued: 0, connecting: 0, discovering: 0, - selecting: 1, - agreeing: 2, - settling: 3, delivering: 3, recovering: 3, - verifying: 4, evaluating: 4, complete: 4, -}; - -const demAmount = (label: string): string => label.match(/([\d.]+)\s*DEM/i)?.[1] ?? ""; - -/** - * Map one gateway event to a speaker + friendly line. Anchors (txRef/anchorRef - * present) become "chain" receipt turns regardless of who triggered them, so - * the ledger reads as a neutral third party. Unknown labels are attributed to - * the Butler verbatim — honest fallback, never invented dialogue. - */ -export function describeEvent(event: ProcurementEvent, runningStage: StageIndex): { speaker: Speaker; stage: StageIndex; text: string; kind: ConversationTurn["kind"] } { - const label = event.label; - const isAnchor = Boolean(event.txRef || event.anchorRef); - const stage = event.phase === "failed" ? runningStage : (PHASE_STAGE[event.phase] ?? runningStage); - - // — Identify — - if (/^Full DACS purchase queued/.test(label)) return { speaker: "butler", stage, kind: "say", text: "I need a security audit on this file, and I want the deal itself to be provable. Finding a seller." }; - if (/Connecting the Butler buyer wallet/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Connecting my wallet and opening a private channel." }; - if (/Resolving the indexed Auditor'?s signed DACS-1 listing/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Pulling the auditor's signed listing from the chain index." }; - if (/failed current-standard verification/.test(label)) return { speaker: "butler", stage, kind: "say", text: "That listing failed verification. Using my configured auditor binding instead." }; - if (/Verified the Auditor listing/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Signed listing verified. The seller's offer is on the record." }; - - // — Vet — - if (/Butler scoring the verified listing/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Scoring the offer against budget, capability and quality." }; - if (/opened a signed RFQ channel/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Auditor: I want a content-bound audit of one file. What are your terms?" }; - if (/Identity Vet record anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Identity vet anchored. The check itself is now on the record." }; - - // — Negotiate — - if (/opened|scoring/.test(label) === false && /RFQ|quote/.test(label)) return { speaker: "seller", stage, kind: "say", text: "Happy to. Let me price it against your files." }; - if (/Buyer\/seller agreement anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Agreed terms anchored." }; - if (/Commitment anchored before payment/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Commitment anchored. Terms are locked before any payment." }; - if (/Buyer and Auditor agreed/.test(label)) { const amt = demAmount(label); return { speaker: "seller", stage, kind: "say", text: amt ? `Agreed: quick scan, standard tier, ${amt} DEM.` : "Terms agreed." }; } - if (/Dual-signed agreement/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Both signatures anchored. Neither side can rewrite the terms." }; - - // — Settle & deliver — - if (/^Paying .*DEM to the negotiated Auditor/.test(label)) { const amt = demAmount(label); return { speaker: "butler", stage, kind: "pay", text: amt ? `Sending ${amt} DEM on the agreed rail.` : "Sending payment." }; } - if (/Payment broadcast on Demos/.test(label)) return { speaker: "chain", stage, kind: "pay", text: "Payment broadcast. Transaction hash below." }; - if (/Auditor verified payment and is scanning/.test(label)) return { speaker: "seller", stage, kind: "say", text: "Payment confirmed. Running the scan." }; - if (/Auditor signed and anchored the content-bound report/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Signed report anchored, bound to the exact file contents." }; - - // — Verify — - if (/Buyer anchoring payment evidence/.test(label)) return { speaker: "butler", stage, kind: "say", text: "Anchoring my payment evidence. Requesting your bundle signature." }; - if (/Settlement evidence anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Buyer settlement evidence anchored." }; - if (/Buyer attestation bundle anchored/.test(label)) return { speaker: "chain", stage, kind: "anchor", text: "Full bundle anchored: listing, vet, terms, payment, delivery." }; - if (/EvalBot applying and signing the acceptance rubric/.test(label)) return { speaker: "referee", stage, kind: "say", text: "Checking the delivery against the agreed rubric. Verdict signed." }; - if (/Purchase settled, report delivered/.test(label)) return { speaker: "seller", stage, kind: "say", text: "Done. Paid and delivered. Anyone can re-verify this deal from the receipts." }; - - // Failures + anything unrecognised: honest, attributed to whoever the phase implies. - if (event.phase === "failed") return { speaker: "butler", stage, kind: "say", text: `Stopped safely: ${label}` }; - return { speaker: isAnchor ? "chain" : "butler", stage, kind: isAnchor ? "anchor" : "say", text: label }; -} - -/** Fold a gateway event stream into the ordered two-agent conversation. */ -export function eventsToConversation(events: readonly ProcurementEvent[]): ConversationTurn[] { - const turns: ConversationTurn[] = []; - let stage: StageIndex = 0; - events.forEach((event, id) => { - const known = event.phase === "failed" ? undefined : PHASE_STAGE[event.phase]; - if (known !== undefined && known > stage) stage = known; - const d = describeEvent(event, stage); - turns.push({ id, speaker: d.speaker, stage: d.stage, text: d.text, raw: event.label, kind: d.kind, txRef: event.txRef, anchorRef: event.anchorRef }); - }); - return turns; -} - -/** - * A REAL successful purchase, captured live from butler.agentcommerce.network - * (job d27cd332, 2026-07-20). Used to animate the demo without spending DEM; - * every tx hash below is a genuine on-chain record. Live runs use the same - * event shape through the same eventsToConversation() path. - */ -export const SAMPLE_PROCUREMENT_EVENTS: ProcurementEvent[] = [ - { phase: "queued", label: "Full DACS purchase queued", at: "2026-07-20T07:51:46.521Z" }, - { phase: "connecting", label: "Connecting the Butler buyer wallet and live L2PS transport", at: "2026-07-20T07:51:46.523Z" }, - { phase: "discovering", label: "Resolving the indexed Auditor's signed DACS-1 listing from chain", at: "2026-07-20T07:51:46.750Z" }, - { phase: "discovering", label: "Verified the Auditor listing advertised by the indexer", at: "2026-07-20T07:51:47.101Z", anchorRef: "stor-d0afc4fa205fb58e53b7aa0403cf1c5b2fe63dcb" }, - { phase: "selecting", label: "Butler scoring the verified listing against budget, capability, quality and rail", at: "2026-07-20T07:51:47.515Z" }, - { phase: "selecting", label: "Butler opened a signed RFQ channel with dacs-auditor", at: "2026-07-20T07:51:47.923Z" }, - { phase: "selecting", label: "Identity Vet record anchored", at: "2026-07-20T07:52:04.330Z", txRef: "658eafe7a97358e42578637a7a9d4c3340a75666040989398e5ff0937f956274", anchorRef: "stor-dd84a65499fc8db99febddab951ac32bcf5505d9" }, - { phase: "selecting", label: "Buyer/seller agreement anchored", at: "2026-07-20T07:52:35.270Z", txRef: "07e9131d3d130da9aba6be8108034a331755a940f92ee56470fb43128e3fa720", anchorRef: "stor-39b76601730caaac37e6a22bb45b5e05a0616a31" }, - { phase: "selecting", label: "Commitment anchored before payment", at: "2026-07-20T07:52:46.243Z", txRef: "c4751eaacb21a390cf8e5c83df14e4a43f9ba34c0630a66445f9230207d8410b", anchorRef: "stor-18f86935eb3e02a37f12fa64022015457780f857" }, - { phase: "agreeing", label: "Buyer and Auditor agreed quick/standard at 2.35 DEM", at: "2026-07-20T07:52:48.333Z" }, - { phase: "agreeing", label: "Dual-signed agreement and commitment anchored before payment", at: "2026-07-20T07:52:48.333Z", anchorRef: "stor-18f86935eb3e02a37f12fa64022015457780f857" }, - { phase: "settling", label: "Paying 2.35 DEM to the negotiated Auditor", at: "2026-07-20T07:52:48.333Z" }, - { phase: "settling", label: "Payment broadcast on Demos", at: "2026-07-20T07:52:49.968Z", txRef: "53dd8a7b34f7d29377c27599e17a5742b2c7296dd048b1235c04359957e0ff24" }, - { phase: "delivering", label: "Auditor verified payment and is scanning the posted source", at: "2026-07-20T07:52:57.985Z" }, - { phase: "delivering", label: "Auditor signed and anchored the content-bound report", at: "2026-07-20T07:53:22.268Z", anchorRef: "stor-fca0580b7b5509b665027cf985cea0942e517056" }, - { phase: "verifying", label: "Buyer anchoring payment evidence and requesting the Auditor's bundle signature", at: "2026-07-20T07:53:22.269Z" }, - { phase: "verifying", label: "Settlement evidence anchored", at: "2026-07-20T07:53:43.083Z", txRef: "e8ed20357a654c4d98acbd3383f07d2306cf0bd63ff8f95533c4b79b780414aa", anchorRef: "stor-d5f0a0fc96e0bc89b6471fa345085748c3f31bf2" }, - { phase: "verifying", label: "Buyer attestation bundle anchored", at: "2026-07-20T07:54:15.328Z", txRef: "7201df09090a43f73c97057659b7daa44a3314c348eab4c22e1cabf21118224b", anchorRef: "stor-e73f88f1b09364b895390cd40ba7b40832582233" }, - { phase: "evaluating", label: "EvalBot applying and signing the acceptance rubric", at: "2026-07-20T07:54:15.665Z" }, - { phase: "complete", label: "Purchase settled, report delivered, and full DACS bundle verified", at: "2026-07-20T07:54:15.745Z" }, -]; diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts b/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts deleted file mode 100644 index a4cb25e..0000000 --- a/reference-implementations/dacs-directory/src/components/try-dacs-contract.ts +++ /dev/null @@ -1,689 +0,0 @@ -export type AgentCard = { - name: string; - label: string; - summary: string; - tags: string[]; - exampleGoal: string; - exampleInput: Record; - /** Optional gateway-published execution mode (passed through verbatim). */ - mode?: string; - /** Optional gateway-published input field schema (name/type/required/description). */ - input?: unknown; -}; - -export type PaymentRail = "pay-dem" | "pay-x402"; - -export type RailGovernance = { - status: string; - conformantAuthority: boolean; - signer: string; - disclosure: string; -}; - -export type ProcurementRailInput = { - rail: PaymentRail; - fields: unknown[]; - sampleInput: Record; -}; - -export type ProcurementRailReadiness = { - executable: boolean; - reasons: string[]; - railGovernance?: RailGovernance; -}; - -export type ProcurementProfile = { - id: string; - title: string; - agentName: string; - serviceId: string; - mode: string; - negotiationPhase: string; - summary: string; - fields: unknown[]; - sampleInput: Record; - timing: { - healthyMinSec: number; - healthyMaxSec: number; - hardTimeoutSec: number; - protocolFloorSec: number; - }; - executionControl: { - model: "server-orchestrated"; - interactiveConfirmation: false; - }; - buyerControl: { - model: "gateway-custodied-demo"; - acceptsExternalDacsIdentity: false; - acceptsExternalPaymentSigner: false; - }; - paymentRails: PaymentRail[]; - railInputs: ProcurementRailInput[]; - railReadiness: Record; - implementationStatus: string; - executable: boolean; - reasons: string[]; -}; - -const PROFILE_AGENT_NAMES: Record = { - "oracle-auto-accept": "oracle-desk", - "dd-live-fixed": "dd-researcher", - "security-audit-rfq": "procurement-butler", -}; - -/** Only these production profiles are deliberately presented as live demos. */ -export const LIVE_PROCUREMENT_PROFILE_IDS = Object.freeze(Object.keys(PROFILE_AGENT_NAMES)); - -export type ProcurementEvent = { - phase: string; - label: string; - at: string; - txRef?: string; - anchorRef?: string; -}; - -export type ProcurementPreview = { - kind: "dacs-procurement-delivery-preview"; - status: "delivery-verified-finalising-dacs5" | "report-verified-finalising-dacs5"; - jobId: string; - delivery: { - verified: true; - report: Record; - }; - anchors: { - listing?: string; - agreement?: string; - commitment?: string; - paymentEvidence?: string; - delivery?: string; - deliveryEvidence?: string; - }; -}; - -export type ProcurementJob = { - id: string; - status: "running" | "complete" | "failed"; - phase: string; - events: ProcurementEvent[]; - /** - * A verified delivery that is safe to inspect while DACS-5 continues. - * This is deliberately nonterminal and must never be treated as acceptance. - */ - preview?: ProcurementPreview; - result?: unknown; - error?: string; - /** - * Gateway-published only on failed jobs: true means NO payment was - * broadcast, so a fresh purchase is safe. Absent or false must be treated - * as "a payment may exist" — never re-purchase. - */ - failedBeforePayment?: boolean; - queue?: { - status: "waiting" | "active" | "finished"; - enqueuedAt: string; - position?: number; - startedAt?: string; - waitMs?: number; - finishedAt?: string; - }; -}; - -export type ProcurementEvidence = { - statusAccepted: boolean; - paymentRecorded: boolean; - negotiationSigned: boolean; - negotiationVerified: boolean; - deliveryVerified: boolean; - bundlesVerified: boolean; - reconciled: boolean; - rulingValid: boolean; - rulingAccepted: boolean; - rulingRequired: boolean; - overallAccepted: boolean; -}; - -export type ReceiptStatus = "queued" | "anchoring" | "broadcast" | "confirmed" | "failed"; - -/** - * The gateway publishes two attestation shapes: the asynchronous receipt - * (receiptId + statusUrl, pollable/retryable) and the synchronous - * LIVE-ANCHOR-storage attestation (digest + anchor + txRef, no status URL). - * Both carry status/digest/anchorAddress/note; polling fields are optional - * and the UI only polls or retries when a statusUrl is present. - */ -export type OutputReceipt = { - receiptId?: string; - statusUrl?: string; - status: ReceiptStatus; - attempts?: number; - createdAt?: string; - updatedAt?: string; - digest: string; - anchorAddress: string; - anchorName?: string; - scheme?: string; - committedBy?: string; - txRef?: string; - error?: string; - note: string; -}; - -export type ButlerRun = Record & { - result: unknown; - execution?: { requestId: string; durationMs: number }; - outputAttestation?: OutputReceipt; -}; - -export class ButlerContractError extends Error { - constructor(path: string, expectation: string) { - super(`Butler gateway returned an invalid ${path}; expected ${expectation}`); - this.name = "ButlerContractError"; - } -} - -export class AgentInputError extends Error { - constructor() { - super("Job details must be a JSON object, not a string, array, or null."); - this.name = "AgentInputError"; - } -} - -export const PROCUREMENT_TIMEOUT_MESSAGE = "The full procurement flow exceeded its 12-minute deadline."; -export const AGENT_TIMEOUT_MESSAGE = "The specialist did not respond within 2 minutes. You can retry or cancel safely."; - -export function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -} - -function requiredRecord(value: unknown, path: string): Record { - const parsed = record(value); - if (Object.keys(parsed).length === 0 && (value === null || typeof value !== "object" || Array.isArray(value))) { - throw new ButlerContractError(path, "an object"); - } - return parsed; -} - -function requiredString(value: unknown, path: string): string { - if (typeof value !== "string" || !value.trim()) throw new ButlerContractError(path, "a non-empty string"); - return value; -} - -function optionalString(value: unknown, path: string): string | undefined { - if (value === undefined) return undefined; - return requiredString(value, path); -} - -function requiredNumber(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) throw new ButlerContractError(path, "a finite number"); - return value; -} - -function requiredBoolean(value: unknown, path: string): boolean { - if (typeof value !== "boolean") throw new ButlerContractError(path, "a boolean"); - return value; -} - -function requiredStringArray(value: unknown, path: string): string[] { - if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { - throw new ButlerContractError(path, "an array of strings"); - } - return value; -} - -function requiredPaymentRail(value: unknown, path: string): PaymentRail { - if (value !== "pay-dem" && value !== "pay-x402") { - throw new ButlerContractError(path, '"pay-dem" or "pay-x402"'); - } - return value; -} - -function requiredHttpsUrl(value: unknown, path: string): string { - const text = requiredString(value, path); - try { - const url = new URL(text); - if (url.protocol !== "https:" || url.username || url.password) throw new Error("unsafe URL"); - return url.toString(); - } catch { - throw new ButlerContractError(path, "a public HTTPS URL without credentials"); - } -} - -export function parseAgentInput(value: unknown): Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) throw new AgentInputError(); - return value as Record; -} - -export async function fetchJsonBeforeDeadline( - input: RequestInfo | URL, - init: RequestInit | undefined, - deadlineMs: number, - fetcher: typeof fetch = fetch, - timeoutMessage = PROCUREMENT_TIMEOUT_MESSAGE, -): Promise<{ response: Response; body: T }> { - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) throw new Error(timeoutMessage); - const controller = new AbortController(); - const externalSignal = init?.signal; - const abortFromExternal = () => controller.abort(externalSignal?.reason); - if (externalSignal?.aborted) abortFromExternal(); - else externalSignal?.addEventListener("abort", abortFromExternal, { once: true }); - let deadlineExpired = false; - const timer = setTimeout(() => { - deadlineExpired = true; - controller.abort(); - }, remainingMs); - try { - const response = await fetcher(input, { ...init, signal: controller.signal }); - const body = await response.json() as T; - return { response, body }; - } catch (cause) { - if (deadlineExpired) throw new Error(timeoutMessage); - throw cause; - } finally { - clearTimeout(timer); - externalSignal?.removeEventListener("abort", abortFromExternal); - } -} - -export function fetchJsonWithTimeout( - input: RequestInfo | URL, - init: RequestInit | undefined, - timeoutMs: number, - timeoutMessage: string, - fetcher: typeof fetch = fetch, -): Promise<{ response: Response; body: T }> { - return fetchJsonBeforeDeadline(input, init, Date.now() + timeoutMs, fetcher, timeoutMessage); -} - -export function parseAgentCatalog(value: unknown): AgentCard[] { - const body = requiredRecord(value, "agent catalog"); - if (!Array.isArray(body.agents)) throw new ButlerContractError("agent catalog.agents", "an array"); - if (body.agents.length === 0) throw new ButlerContractError("agent catalog.agents", "at least one agent"); - return body.agents.map((value, index) => { - const path = `agent catalog.agents[${index}]`; - const agent = requiredRecord(value, path); - if (!Array.isArray(agent.tags) || agent.tags.some((tag) => typeof tag !== "string")) { - throw new ButlerContractError(`${path}.tags`, "an array of strings"); - } - return { - name: requiredString(agent.name, `${path}.name`), - label: requiredString(agent.label, `${path}.label`), - summary: requiredString(agent.summary, `${path}.summary`), - tags: agent.tags, - exampleGoal: requiredString(agent.exampleGoal, `${path}.exampleGoal`), - exampleInput: requiredRecord(agent.exampleInput, `${path}.exampleInput`), - // Pass the gateway's own schema surface through untouched; the form - // layer decides whether it can render it (and falls back safely). - mode: optionalString(agent.mode, `${path}.mode`), - input: agent.input, - }; - }); -} - -export function parseProcurementProfiles(value: unknown): ProcurementProfile[] { - const body = requiredRecord(value, "procurement options"); - if (!Array.isArray(body.profiles)) throw new ButlerContractError("procurement options.profiles", "an array"); - return body.profiles.map((value, index) => { - const path = `procurement options.profiles[${index}]`; - const profile = requiredRecord(value, path); - const timing = requiredRecord(profile.timing, `${path}.timing`); - if (!Array.isArray(profile.fields)) throw new ButlerContractError(`${path}.fields`, "an array"); - const paymentRails = requiredStringArray(profile.paymentRails, `${path}.paymentRails`) - .map((rail, railIndex) => requiredPaymentRail(rail, `${path}.paymentRails[${railIndex}]`)); - if (paymentRails.length === 0 || new Set(paymentRails).size !== paymentRails.length) { - throw new ButlerContractError(`${path}.paymentRails`, "one or more unique supported rails"); - } - if (!Array.isArray(profile.railInputs)) throw new ButlerContractError(`${path}.railInputs`, "an array"); - const railInputs = profile.railInputs.map((value, railIndex): ProcurementRailInput => { - const railPath = `${path}.railInputs[${railIndex}]`; - const input = requiredRecord(value, railPath); - if (!Array.isArray(input.fields)) throw new ButlerContractError(`${railPath}.fields`, "an array"); - const rail = requiredPaymentRail(input.rail, `${railPath}.rail`); - const sampleInput = requiredRecord(input.sampleInput, `${railPath}.sampleInput`); - if (sampleInput.paymentRail !== rail) throw new ButlerContractError(`${railPath}.sampleInput.paymentRail`, rail); - return { - rail, - fields: input.fields, - sampleInput, - }; - }); - if (new Set(railInputs.map((input) => input.rail)).size !== railInputs.length) { - throw new ButlerContractError(`${path}.railInputs`, "one unique schema per supported rail"); - } - const readinessSource = requiredRecord(profile.railReadiness, `${path}.railReadiness`); - const railReadiness: Record = { "pay-dem": undefined, "pay-x402": undefined }; - for (const rail of paymentRails) { - const readinessPath = `${path}.railReadiness.${rail}`; - const readiness = requiredRecord(readinessSource[rail], readinessPath); - const governanceSource = readiness.railGovernance; - let railGovernance: RailGovernance | undefined; - if (governanceSource !== undefined) { - const governance = requiredRecord(governanceSource, `${readinessPath}.railGovernance`); - railGovernance = { - status: requiredString(governance.status, `${readinessPath}.railGovernance.status`), - conformantAuthority: requiredBoolean(governance.conformantAuthority, `${readinessPath}.railGovernance.conformantAuthority`), - signer: requiredString(governance.signer, `${readinessPath}.railGovernance.signer`), - disclosure: requiredHttpsUrl(governance.disclosure, `${readinessPath}.railGovernance.disclosure`), - }; - } - railReadiness[rail] = { - executable: requiredBoolean(readiness.executable, `${readinessPath}.executable`), - reasons: requiredStringArray(readiness.reasons, `${readinessPath}.reasons`), - ...(railGovernance ? { railGovernance } : {}), - }; - } - for (const rail of paymentRails) { - if (!railInputs.some((input) => input.rail === rail)) { - throw new ButlerContractError(`${path}.railInputs`, `an input schema for ${rail}`); - } - } - const hasExecutionControl = profile.executionControl !== undefined; - const hasBuyerControl = profile.buyerControl !== undefined; - let executionControl: ProcurementProfile["executionControl"]; - let buyerControl: ProcurementProfile["buyerControl"]; - if (!hasExecutionControl && !hasBuyerControl) { - // One-release compatibility for the already-deployed gateway contract. - // `confirmationGates` described phases but the gateway never paused; - // normalize that old shape to what it actually did. - requiredStringArray(profile.confirmationGates, `${path}.confirmationGates`); - executionControl = { model: "server-orchestrated", interactiveConfirmation: false }; - buyerControl = { - model: "gateway-custodied-demo", - acceptsExternalDacsIdentity: false, - acceptsExternalPaymentSigner: false, - }; - } else { - const source = requiredRecord(profile.executionControl, `${path}.executionControl`); - if (source.model !== "server-orchestrated" || source.interactiveConfirmation !== false) { - throw new ButlerContractError( - `${path}.executionControl`, - 'the supported server-orchestrated, non-interactive execution model', - ); - } - executionControl = { model: "server-orchestrated", interactiveConfirmation: false }; - const buyerSource = requiredRecord(profile.buyerControl, `${path}.buyerControl`); - if (buyerSource.model !== "gateway-custodied-demo" - || buyerSource.acceptsExternalDacsIdentity !== false - || buyerSource.acceptsExternalPaymentSigner !== false) { - throw new ButlerContractError( - `${path}.buyerControl`, - "the supported gateway-custodied demo buyer model", - ); - } - buyerControl = { - model: "gateway-custodied-demo", - acceptsExternalDacsIdentity: false, - acceptsExternalPaymentSigner: false, - }; - } - return { - id: requiredString(profile.id, `${path}.id`), - title: requiredString(profile.title, `${path}.title`), - agentName: requiredString(profile.agentName, `${path}.agentName`), - serviceId: requiredString(profile.serviceId, `${path}.serviceId`), - mode: requiredString(profile.mode, `${path}.mode`), - negotiationPhase: requiredString(profile.negotiationPhase, `${path}.negotiationPhase`), - summary: requiredString(profile.summary, `${path}.summary`), - fields: profile.fields, - sampleInput: requiredRecord(profile.sampleInput, `${path}.sampleInput`), - timing: { - healthyMinSec: requiredNumber(timing.healthyMinSec, `${path}.timing.healthyMinSec`), - healthyMaxSec: requiredNumber(timing.healthyMaxSec, `${path}.timing.healthyMaxSec`), - hardTimeoutSec: requiredNumber(timing.hardTimeoutSec, `${path}.timing.hardTimeoutSec`), - protocolFloorSec: requiredNumber(timing.protocolFloorSec, `${path}.timing.protocolFloorSec`), - }, - executionControl, - buyerControl, - paymentRails, - railInputs, - railReadiness, - implementationStatus: requiredString(profile.implementationStatus, `${path}.implementationStatus`), - executable: requiredBoolean(profile.executable, `${path}.executable`), - reasons: requiredStringArray(profile.reasons, `${path}.reasons`), - }; - }); -} - -export function procurementRailInput(profile: ProcurementProfile, rail: PaymentRail): ProcurementRailInput { - const input = profile.railInputs.find((candidate) => candidate.rail === rail); - if (!input) throw new ButlerContractError(`procurement profile ${profile.id}`, `an input schema for ${rail}`); - return input; -} - -export function procurementProfileCard(profile: ProcurementProfile, rail: PaymentRail = profile.paymentRails[0]!): AgentCard { - const name = PROFILE_AGENT_NAMES[profile.id]; - if (!name) throw new ButlerContractError(`procurement profile ${profile.id}`, "a supported live demo profile"); - const railInput = procurementRailInput(profile, rail); - return { - name, - label: profile.agentName, - summary: profile.summary, - tags: [profile.mode, profile.serviceId, rail], - exampleGoal: profile.title, - exampleInput: railInput.sampleInput, - mode: profile.mode, - input: railInput.fields, - }; -} - -const PREVIEW_ANCHORS = [ - "listing", - "agreement", - "commitment", - "paymentEvidence", - "delivery", - "deliveryEvidence", -] as const; - -function parseProcurementPreview(value: unknown): ProcurementPreview { - const preview = requiredRecord(value, "procurement job.preview"); - if (preview.kind !== "dacs-procurement-delivery-preview") { - throw new ButlerContractError("procurement job.preview.kind", '"dacs-procurement-delivery-preview"'); - } - if (preview.status !== "delivery-verified-finalising-dacs5" && preview.status !== "report-verified-finalising-dacs5") { - throw new ButlerContractError( - "procurement job.preview.status", - '"delivery-verified-finalising-dacs5" or "report-verified-finalising-dacs5"', - ); - } - const delivery = requiredRecord(preview.delivery, "procurement job.preview.delivery"); - if (requiredBoolean(delivery.verified, "procurement job.preview.delivery.verified") !== true) { - throw new ButlerContractError("procurement job.preview.delivery.verified", "true"); - } - const report = requiredRecord(delivery.report, "procurement job.preview.delivery.report"); - if (Object.keys(report).length === 0) { - throw new ButlerContractError("procurement job.preview.delivery.report", "a non-empty verified report object"); - } - const rawAnchors = requiredRecord(preview.anchors, "procurement job.preview.anchors"); - const anchors: ProcurementPreview["anchors"] = {}; - for (const key of PREVIEW_ANCHORS) { - const anchor = optionalString(rawAnchors[key], `procurement job.preview.anchors.${key}`); - if (anchor !== undefined) anchors[key] = anchor; - } - if (!anchors.delivery || !anchors.paymentEvidence) { - throw new ButlerContractError("procurement job.preview.anchors", "delivery and paymentEvidence references"); - } - return { - kind: preview.kind, - status: preview.status, - jobId: requiredString(preview.jobId, "procurement job.preview.jobId"), - delivery: { verified: true, report }, - anchors, - }; -} - -export function parseProcurementJob(value: unknown): ProcurementJob { - const job = requiredRecord(value, "procurement job"); - if (job.status !== "running" && job.status !== "complete" && job.status !== "failed") { - throw new ButlerContractError("procurement job.status", '"running", "complete", or "failed"'); - } - if (!Array.isArray(job.events)) throw new ButlerContractError("procurement job.events", "an array"); - const events = job.events.map((value, index) => { - const path = `procurement job.events[${index}]`; - const event = requiredRecord(value, path); - return { - phase: requiredString(event.phase, `${path}.phase`), - label: requiredString(event.label, `${path}.label`), - at: requiredString(event.at, `${path}.at`), - txRef: optionalString(event.txRef, `${path}.txRef`), - anchorRef: optionalString(event.anchorRef, `${path}.anchorRef`), - }; - }); - if (job.status === "complete") requiredRecord(job.result, "procurement job.result"); - // A terminal producer may omit the nonterminal preview or serialize it as - // JSON null. Both mean "no preview" and neither manufactures a result. - const preview = job.preview === undefined || job.preview === null ? undefined : parseProcurementPreview(job.preview); - if (job.status === "complete" && preview) { - throw new ButlerContractError("procurement job.preview", "no preview after terminal completion"); - } - if (job.failedBeforePayment !== undefined && typeof job.failedBeforePayment !== "boolean") { - throw new ButlerContractError("procurement job.failedBeforePayment", "a boolean"); - } - let queue: ProcurementJob["queue"]; - if (job.queue !== undefined) { - const rawQueue = requiredRecord(job.queue, "procurement job.queue"); - if (rawQueue.status !== "waiting" && rawQueue.status !== "active" && rawQueue.status !== "finished") { - throw new ButlerContractError("procurement job.queue.status", '"waiting", "active", or "finished"'); - } - const position = optionalNumber(rawQueue.position, "procurement job.queue.position"); - const waitMs = optionalNumber(rawQueue.waitMs, "procurement job.queue.waitMs"); - if (position !== undefined && (!Number.isSafeInteger(position) || position < 1)) { - throw new ButlerContractError("procurement job.queue.position", "a positive integer"); - } - if (waitMs !== undefined && waitMs < 0) { - throw new ButlerContractError("procurement job.queue.waitMs", "a non-negative number"); - } - queue = { - status: rawQueue.status, - enqueuedAt: requiredString(rawQueue.enqueuedAt, "procurement job.queue.enqueuedAt"), - position, - startedAt: optionalString(rawQueue.startedAt, "procurement job.queue.startedAt"), - waitMs, - finishedAt: optionalString(rawQueue.finishedAt, "procurement job.queue.finishedAt"), - }; - } - return { - id: requiredString(job.id, "procurement job.id"), - status: job.status, - phase: requiredString(job.phase, "procurement job.phase"), - events, - preview, - result: job.result, - error: optionalString(job.error, "procurement job.error"), - failedBeforePayment: job.failedBeforePayment, - queue, - }; -} - -function optionalNumber(value: unknown, path: string): number | undefined { - if (value === undefined) return undefined; - return requiredNumber(value, path); -} - -function parseOutputReceipt(value: unknown, path: string): OutputReceipt { - const receipt = requiredRecord(value, path); - if (receipt.status !== "queued" && receipt.status !== "anchoring" && receipt.status !== "broadcast" && receipt.status !== "confirmed" && receipt.status !== "failed") { - throw new ButlerContractError(`${path}.status`, '"queued", "anchoring", "broadcast", "confirmed", or "failed"'); - } - return { - receiptId: optionalString(receipt.receiptId, `${path}.receiptId`), - statusUrl: optionalString(receipt.statusUrl, `${path}.statusUrl`), - status: receipt.status, - attempts: optionalNumber(receipt.attempts, `${path}.attempts`), - createdAt: optionalString(receipt.createdAt, `${path}.createdAt`), - updatedAt: optionalString(receipt.updatedAt, `${path}.updatedAt`), - digest: requiredString(receipt.digest, `${path}.digest`), - anchorAddress: requiredString(receipt.anchorAddress, `${path}.anchorAddress`), - anchorName: optionalString(receipt.anchorName, `${path}.anchorName`), - scheme: optionalString(receipt.scheme, `${path}.scheme`), - committedBy: optionalString(receipt.committedBy, `${path}.committedBy`), - txRef: optionalString(receipt.txRef, `${path}.txRef`), - error: optionalString(receipt.error, `${path}.error`), - note: requiredString(receipt.note, `${path}.note`), - }; -} - -export function parseReceiptEnvelope(value: unknown): OutputReceipt { - const response = requiredRecord(value, "receipt response"); - return parseOutputReceipt(response.outputAttestation, "receipt response.outputAttestation"); -} - -export function parseButlerRun(value: unknown): ButlerRun { - const response = requiredRecord(value, "Butler response"); - const butler = requiredRecord(response.butler, "Butler response.butler"); - requiredString(butler.selectedAgent, "Butler response.butler.selectedAgent"); - requiredString(butler.label, "Butler response.butler.label"); - if (!("result" in response)) throw new ButlerContractError("Butler response.result", "an agent result"); - let execution: ButlerRun["execution"]; - if (response.execution !== undefined) { - const rawExecution = requiredRecord(response.execution, "Butler response.execution"); - execution = { - requestId: requiredString(rawExecution.requestId, "Butler response.execution.requestId"), - durationMs: requiredNumber(rawExecution.durationMs, "Butler response.execution.durationMs"), - }; - } - const outputAttestation = response.outputAttestation === undefined - ? undefined - : parseOutputReceipt(response.outputAttestation, "Butler response.outputAttestation"); - return { ...response, result: response.result, execution, outputAttestation }; -} - -/** - * The gateway has published two negotiation-signature shapes: a bare - * signature string, and the structured `{ party, algorithm, value }` record. - * Either counts as present when it carries a non-empty signature value. - */ -function signaturePresent(value: unknown): boolean { - if (typeof value === "string") return Boolean(value.trim()); - const structured = record(value); - return typeof structured.value === "string" && Boolean(structured.value.trim()); -} - -export function procurementEvidence(value: unknown, mode = "rfq"): ProcurementEvidence { - const report = record(value); - const settlement = record(report.settlement); - const negotiation = record(report.negotiation); - const delivery = record(report.delivery); - const evaluation = record(report.evaluation); - const ruling = record(evaluation.ruling); - const bundles = record(report.bundleVerification); - const reconciliation = record(report.reconciliation); - const anchors = record(report.anchors); - const transactions = Array.isArray(report.transactions) ? report.transactions.map(record) : []; - const paymentHash = typeof settlement.txHash === "string" ? settlement.txHash.trim() : ""; - const paymentRecorded = Boolean(paymentHash) && transactions.some((transaction) => - transaction.kind === "payment" && transaction.txRef === paymentHash, - ); - const statusAccepted = report.status === "settled-and-accepted" || report.status === "recovered-after-terminal-abort"; - const negotiationSigned = signaturePresent(negotiation.buyerSignature) && signaturePresent(negotiation.sellerSignature); - // Fixed-price flows publish the jointly-signed agreement as the anchored - // agreement/commitment pair rather than projecting both raw signatures into - // the result envelope. DACS-5 bundle verification then binds that agreement - // into the two-party receipt set. RFQ stays fail-closed on both signatures. - const fixedAgreementAnchored = mode.startsWith("fixed-price-") - && negotiation.protocol === "dacs-fixed/1" - && typeof negotiation.agreementHash === "string" && Boolean(negotiation.agreementHash.trim()) - && typeof anchors.agreement === "string" && Boolean(anchors.agreement.trim()) - && typeof anchors.commitment === "string" && Boolean(anchors.commitment.trim()); - const negotiationVerified = negotiationSigned || fixedAgreementAnchored; - const deliveryVerified = delivery.verified === true && Object.keys(record(delivery.report)).length > 0; - const bundlesVerified = bundles.ok === true; - const reconciled = reconciliation.reconciled === true; - const rulingValid = evaluation.rulingValid === true; - const rulingAccepted = evaluation.accepted === true && ruling.verdict === "accept"; - const rulingRequired = mode === "rfq"; - return { - statusAccepted, - paymentRecorded, - negotiationSigned, - negotiationVerified, - deliveryVerified, - bundlesVerified, - reconciled, - rulingValid, - rulingAccepted, - rulingRequired, - overallAccepted: statusAccepted && paymentRecorded && negotiationVerified && deliveryVerified && bundlesVerified && reconciled - && (!rulingRequired || (rulingValid && rulingAccepted)), - }; -} diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-forms.ts b/reference-implementations/dacs-directory/src/components/try-dacs-forms.ts deleted file mode 100644 index 46ad025..0000000 --- a/reference-implementations/dacs-directory/src/components/try-dacs-forms.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * Pure form logic for the /try agent forms: per-agent descriptors, blank - * defaults, local validation, gateway-error mapping, and submission summaries. - * - * The single source of truth for a run is the gateway input object itself — - * controls edit paths inside it, the Advanced JSON view edits the same object, - * and `runAgent()` submits it unchanged. Local validation is advisory for - * immediate feedback; the gateway remains authoritative. - */ -import type { AgentCard, PaymentRail } from "./try-dacs-contract.js"; - -/** - * A renderable field derived from the gateway's published input schema. The - * gateway describes fields as { name, type, required, description } (plus - * optional enum/min/max); this normalizes them for the generic form renderer. - */ -export type AgentFieldSchema = { - key: string; - label: string; - kind: "text" | "number" | "checkbox" | "select" | "textarea"; - required?: boolean; - help?: string; - options?: string[]; - min?: number; - max?: number; -}; - -export type FieldErrors = Record; - -export type SourceFile = { path: string; content: string }; - -export const ORACLE_PRODUCTS = ["crypto-price", "fx-rate", "chain-height"] as const; -export const ORACLE_DEFAULT_PARAMS: Record> = { - "crypto-price": { id: "bitcoin" }, - "fx-rate": { base: "USD", quote: "EUR" }, - "chain-height": {}, -}; -export const DD_KINDS = ["npm-package", "crypto-token"] as const; -export const COMPLIANCE_KINDS = ["person", "entity", "wallet"] as const; -export const EVAL_CHECKS = ["content-includes", "regex-match", "min-length"] as const; -export const ACCOUNT_KINDS = ["operating", "reserve", "payroll"] as const; - -export const KNOWN_FORM_AGENTS = [ - "procurement-butler", "oracle-desk", "dd-researcher", "dep-upgrade", - "evalbot", "treasury-ops", "site-auditor", "sec-audit", "compliance", -] as const; - -const rec = (value: unknown): Record => - value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const arr = (value: unknown): unknown[] => Array.isArray(value) ? value : []; -const str = (value: unknown): string => typeof value === "string" ? value : ""; -const num = (value: unknown): number | undefined => - typeof value === "number" && Number.isFinite(value) ? value : undefined; - -const SCHEMA_KINDS: Record = { - string: "text", - text: "textarea", - number: "number", - integer: "number", - boolean: "checkbox", - enum: "select", -}; - -const schemaFieldKind = (field: Record): AgentFieldSchema["kind"] | null => { - // The gateway expresses an enum as a companion `enum` array on a string - // field ({ type: "string", enum: [...] }); it may also use type: "enum". - if (Array.isArray(field.enum)) return field.enum.every((option) => typeof option === "string") ? "select" : null; - return SCHEMA_KINDS[String(field.type)] ?? null; -}; - -const labelFromName = (name: string): string => - name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").replace(/^./, (c) => c.toUpperCase()); - -/** - * Parse the gateway's published input schema (agents[].input as - * { name, type, required, description }[]) into renderable fields. Fields the - * generic renderer cannot represent (objects, arrays, unknown types) yield - * null so the caller falls back to the built-in form or the Advanced JSON - * editor — a partially-rendered schema would silently drop required input. - */ -export function parseAgentFieldSchema(agent: AgentCard & { input?: unknown }): AgentFieldSchema[] | null { - if (!Array.isArray(agent.input) || agent.input.length === 0) return null; - const fields: AgentFieldSchema[] = []; - for (const value of agent.input) { - const field = rec(value); - if (typeof field.name !== "string" || !field.name.trim()) return null; - const kind = schemaFieldKind(field); - if (!kind) return null; - const options = Array.isArray(field.enum) ? field.enum as string[] : undefined; - fields.push({ - key: field.name, - label: labelFromName(field.name), - kind, - required: field.required === true, - help: typeof field.description === "string" ? field.description : undefined, - options: options as string[] | undefined, - min: num(field.min), - max: num(field.max), - }); - } - return fields; -} - -/** - * Validate an input object against a gateway-published field schema: required - * fields must be present and non-empty; numbers must parse and honour min/max; - * selects must hold one of the published options. Advisory only — the gateway - * remains authoritative. - */ -/** - * Default input for a schema-driven agent. Selects seed to their first option - * and checkboxes to false so what the form displays matches submitted state - * (a required select must not read as "chosen" while its value is empty). - * Text/number fields start absent so their required validation fires. - */ -export function initialSchemaInput(fields: AgentFieldSchema[]): Record { - const input: Record = {}; - for (const field of fields) { - if (field.kind === "checkbox") input[field.key] = false; - else if (field.kind === "select" && field.options && field.options.length > 0) input[field.key] = field.options[0]; - } - return input; -} - -export function validateSchemaInput(fields: AgentFieldSchema[], input: Record): FieldErrors { - const errors: FieldErrors = {}; - for (const field of fields) { - const value = input[field.key]; - const missing = value === undefined || value === null || (typeof value === "string" && value.trim() === ""); - if (field.required && field.kind === "checkbox") { - if (value !== true) errors[field.key] = `Required — ${field.label} must be checked.`; - continue; - } - if (field.required && missing) { - errors[field.key] = `Required — ${field.label}.`; - continue; - } - if (missing) continue; - if (field.kind === "number") { - const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed)) errors[field.key] = `${field.label} must be a number.`; - else if (field.min !== undefined && parsed < field.min) errors[field.key] = `${field.label} must be at least ${field.min}.`; - else if (field.max !== undefined && parsed > field.max) errors[field.key] = `${field.label} must be at most ${field.max}.`; - } else if (field.kind === "select" && field.options && !field.options.includes(String(value))) { - errors[field.key] = `${field.label} must be one of: ${field.options.join(", ")}.`; - } - } - return errors; -} - -/** True when the agent has a dedicated built-in form (vs schema/JSON fallback). */ -export function hasBuiltinForm(name: string): boolean { - return (KNOWN_FORM_AGENTS as readonly string[]).includes(name); -} - -/** Blank starting input per agent — examples are opt-in via "Load example". */ -export function initialAgentInput(name: string, paymentRail: PaymentRail = "pay-dem"): Record { - switch (name) { - case "procurement-butler": - return paymentRail === "pay-x402" - ? { goal: "", budgetUsdc: 0.1, files: [{ path: "", content: "" }], paymentRail } - : { goal: "", budgetDem: 5, files: [{ path: "", content: "" }], paymentRail }; - case "oracle-desk": - return { product: "crypto-price", params: { ...ORACLE_DEFAULT_PARAMS["crypto-price"] }, paymentRail }; - case "dd-researcher": - return { kind: "npm-package", subject: "", paymentRail }; - case "dep-upgrade": - return { packageJson: {}, includeNextMajor: false }; - case "evalbot": - return { - deliverable: { content: "" }, - rubric: { acceptThreshold: 80, criteria: [blankCriterion()] }, - }; - case "treasury-ops": - return { - policy: { accounts: [{ id: "ops", kind: "operating", minBalance: 0 }], allowlist: [], payroll: [], maxPerTransfer: 0, maxPerRun: 0 }, - balances: { ops: 0 }, - }; - case "site-auditor": - return { url: "", samples: 1 }; - case "sec-audit": - return { files: [{ path: "", content: "" }] }; - case "compliance": - return { kind: "entity", name: "" }; - default: - return {}; - } -} - -export function blankCriterion(): Record { - return { id: "", kind: "mechanical", weight: 1, description: "", test: { check: "content-includes", needle: "" } }; -} - -/** - * Switching a criterion's mechanical check swaps its parameters: needle for - * content-includes / regex-match, minChars for min-length. Stale parameters - * from the previous check must not be submitted (probed live: min-length - * without minChars silently scores "length < min undefined"). - */ -export function criterionCheckChange(test: Record, check: string): Record { - if (check === "min-length") { - return { check, minChars: typeof test.minChars === "number" ? test.minChars : 1 }; - } - if (check === "regex-match") { - // The gateway runs new RegExp(test.pattern); an undefined pattern matches - // everything and would falsely accept. Carry a string over from either key. - const carried = typeof test.pattern === "string" ? test.pattern : typeof test.needle === "string" ? test.needle : ""; - return { check, pattern: carried }; - } - const carried = typeof test.needle === "string" ? test.needle : typeof test.pattern === "string" ? test.pattern : ""; - return { check, needle: carried }; -} - -/** Repeatable-row helpers (pure; unit-tested). */ -export function addRow(rows: T[], blank: T): T[] { - return [...rows, blank]; -} -export function removeRow(rows: T[], index: number, minimum = 0): T[] { - if (rows.length <= minimum) return rows; - return rows.filter((_, i) => i !== index); -} - -// Gateway-valid targets include bare hosts (example.com) as well as full -// URLs; local validation must not reject what the gateway accepts. -const URL_PATTERN = /^(https?:\/\/)?[^\s/]+\.[^\s]{2,}/i; - -/** - * Advisory local validation: immediate feedback only. Keys are dotted paths - * matching the controls; the gateway's own validation remains authoritative. - */ -export function validateAgentInput(name: string, input: Record): FieldErrors { - const errors: FieldErrors = {}; - const requireText = (key: string, value: unknown, why: string) => { - if (!str(value).trim()) errors[key] = why; - }; - const requireFiles = (key: string, value: unknown) => { - const files = arr(value); - if (files.length === 0) { errors[key] = "Add at least one file."; return; } - files.forEach((file, index) => { - const f = rec(file); - if (!str(f.path).trim()) errors[`${key}.${index}.path`] = "Required so the report can reference this file."; - if (!str(f.content).trim()) errors[`${key}.${index}.content`] = "Required — this is the content the agent analyses."; - }); - }; - switch (name) { - case "procurement-butler": { - requireText("goal", input.goal, "Required — this states what you are purchasing."); - if (input.paymentRail === "pay-x402") { - const budget = num(input.budgetUsdc); - if (budget === undefined || budget <= 0 || budget > 10) errors.budgetUsdc = "Enter a Base Sepolia USDC budget above 0 and no more than 10."; - } else { - const budget = num(input.budgetDem); - if (budget === undefined || budget <= 0 || budget > 10) errors.budgetDem = "Enter a DEM budget above 0 and no more than 10."; - } - requireFiles("files", input.files); - break; - } - case "oracle-desk": { - const product = str(input.product); - if (!(ORACLE_PRODUCTS as readonly string[]).includes(product)) { - errors.product = `Pick one of: ${ORACLE_PRODUCTS.join(", ")}.`; - break; - } - const params = rec(input.params); - if (product === "crypto-price") { - requireText("params.id", params.id, "Required — enter a CoinGecko coin id."); - } else if (product === "fx-rate") { - requireText("params.base", params.base, "Required — enter the currency to convert from."); - requireText("params.quote", params.quote, "Required — enter the currency to convert to."); - } - break; - } - case "dd-researcher": { - if (!(DD_KINDS as readonly string[]).includes(str(input.kind))) errors.kind = "Pick npm package or crypto token."; - requireText("subject", input.subject, input.kind === "crypto-token" - ? "Required — the CoinGecko ID to research (e.g. bitcoin)." - : "Required — the npm package name to research (e.g. express)."); - break; - } - case "dep-upgrade": { - const pkg = input.packageJson; - if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) errors.packageJson = "Provide a JSON object (your package.json)."; - else if (Object.keys(rec(pkg)).length === 0) errors.packageJson = "Paste a package.json with at least one field."; - break; - } - case "evalbot": { - requireText("deliverable.content", rec(input.deliverable).content, "Required — the content EvalBot scores."); - const rubric = rec(input.rubric); - const threshold = num(rubric.acceptThreshold); - if (threshold === undefined || threshold < 0 || threshold > 100) errors["rubric.acceptThreshold"] = "Enter a 0–100 acceptance threshold."; - const criteria = arr(rubric.criteria); - if (criteria.length === 0) errors["rubric.criteria"] = "Add at least one criterion."; - criteria.forEach((value, index) => { - const criterion = rec(value); - if (!str(criterion.id).trim()) errors[`rubric.criteria.${index}.id`] = "Required — a short identifier for this criterion."; - const weight = num(criterion.weight); - if (weight === undefined || weight <= 0) errors[`rubric.criteria.${index}.weight`] = "Positive weight required."; - if (!str(criterion.description).trim()) errors[`rubric.criteria.${index}.description`] = "Describe what this criterion checks."; - const test = rec(criterion.test); - const check = str(test.check); - if (check === "min-length") { - const minChars = num(test.minChars); - if (minChars === undefined || !Number.isInteger(minChars) || minChars < 1) { - errors[`rubric.criteria.${index}.test.minChars`] = "Required — the minimum character count this check enforces."; - } - } else if (check === "regex-match") { - const pattern = str(test.pattern); - if (!pattern.trim()) { - errors[`rubric.criteria.${index}.test.pattern`] = "Required — a pattern (an empty pattern matches everything)."; - } else { - try { new RegExp(pattern); } - catch { errors[`rubric.criteria.${index}.test.pattern`] = "Not a valid regular expression."; } - } - } else if (!str(test.needle).trim()) { - errors[`rubric.criteria.${index}.test.needle`] = "Required — the text the mechanical check looks for."; - } - }); - break; - } - case "treasury-ops": { - const policy = rec(input.policy); - const accounts = arr(policy.accounts); - if (accounts.length === 0) errors["policy.accounts"] = "Add at least one account."; - accounts.forEach((value, index) => { - const account = rec(value); - if (!str(account.id).trim()) errors[`policy.accounts.${index}.id`] = "Required — the account identifier."; - if (num(account.minBalance) === undefined || (num(account.minBalance) ?? -1) < 0) errors[`policy.accounts.${index}.minBalance`] = "Non-negative minimum balance required."; - }); - const perTransfer = num(policy.maxPerTransfer); - const perRun = num(policy.maxPerRun); - if (perTransfer === undefined || perTransfer <= 0) errors["policy.maxPerTransfer"] = "Positive per-transfer cap required."; - if (perRun === undefined || perRun <= 0) errors["policy.maxPerRun"] = "Positive per-run cap required."; - const balances = rec(input.balances); - accounts.forEach((value, index) => { - const id = str(rec(value).id).trim(); - if (id && num(balances[id]) === undefined) errors[`balances.${id}`] = `Enter a numeric balance for "${id}".`; - void index; - }); - break; - } - case "site-auditor": { - if (!URL_PATTERN.test(str(input.url).trim())) errors.url = "Enter a site to audit, e.g. https://example.com or example.com."; - const samples = num(input.samples); - if (samples === undefined || !Number.isInteger(samples) || samples < 1 || samples > 5) errors.samples = "Between 1 and 5 samples."; - break; - } - case "sec-audit": { - requireFiles("files", input.files); - const pkg = input.packageJson; - if (pkg !== undefined && (typeof pkg !== "object" || pkg === null || Array.isArray(pkg))) errors.packageJson = "Optional, but must be a JSON object when provided."; - break; - } - case "compliance": { - if (!(COMPLIANCE_KINDS as readonly string[]).includes(str(input.kind))) errors.kind = "Pick person, entity, or wallet."; - if (input.kind === "wallet") requireText("walletAddress", input.walletAddress, "Required — the wallet address to screen."); - else requireText("name", input.name, "Required — the full legal name to screen."); - if (input.country !== undefined && typeof input.country !== "string") { - errors.country = "Country must be an ISO code string, e.g. GB."; - } - const aliases = input.aliases; - if (aliases !== undefined && (!Array.isArray(aliases) || aliases.some((alias) => typeof alias !== "string"))) { - errors.aliases = "Aliases must be a list of names."; - } - break; - } - default: - break; - } - return errors; -} - -/** - * Switching the compliance kind must not submit stale conditional fields: - * person/entity carry `name`, wallet carries `walletAddress`. - */ -export function complianceKindChange(input: Record, kind: string): Record { - const next: Record = { ...input, kind }; - if (kind === "wallet") { - delete next.name; - if (typeof next.walletAddress !== "string") next.walletAddress = ""; - } else { - delete next.walletAddress; - if (typeof next.name !== "string") next.name = ""; - } - return next; -} - -/** Oracle product change resets params to that product's known defaults. */ -export function oracleProductChange(input: Record, product: string): Record { - return { ...input, product, params: { ...(ORACLE_DEFAULT_PARAMS[product] ?? {}) } }; -} - -/** - * Map the gateway's error envelope onto form fields. `details` lines follow - * `field "name"…`; agent errors follow `… requires fieldName`. Anything that - * cannot be attributed to a field stays in `global`. - */ -export function mapGatewayErrors( - message: string, - details: string[] | undefined, - knownKeys: string[], -): { global: string[]; byField: FieldErrors } { - const byField: FieldErrors = {}; - const global: string[] = []; - const attribute = (line: string): boolean => { - const quoted = line.match(/field "([^"]+)"/)?.[1]; - if (quoted) { - const key = knownKeys.find((candidate) => candidate === quoted || candidate.endsWith(`.${quoted}`)) ?? quoted; - byField[key] = byField[key] ? `${byField[key]} ${line}` : line; - return true; - } - const required = line.match(/requires (\w+)/)?.[1]; - if (required) { - const key = knownKeys.find((candidate) => candidate === required || candidate.endsWith(`.${required}`)) ?? required; - byField[key] = byField[key] ? `${byField[key]} ${line}` : line; - return true; - } - return false; - }; - for (const line of details ?? []) if (!attribute(line)) global.push(line); - if ((details ?? []).length === 0 && !attribute(message)) global.push(message); - return { global, byField }; -} - -/** All dotted paths present in an input object (for gateway-error mapping). */ -export function flattenInputKeys(value: unknown, prefix = ""): string[] { - if (Array.isArray(value)) { - return value.flatMap((item, index) => flattenInputKeys(item, prefix ? `${prefix}.${index}` : String(index))); - } - if (value && typeof value === "object") { - return Object.entries(value as Record).flatMap(([key, item]) => { - const path = prefix ? `${prefix}.${key}` : key; - return [path, ...flattenInputKeys(item, path)]; - }); - } - return []; -} - -/** Short human summary of exactly what will be / was submitted. */ -export function summarizeAgentInput(name: string, input: Record): string[] { - const lines: string[] = []; - switch (name) { - case "procurement-butler": { - lines.push(`Goal: ${str(input.goal) || "—"}`); - lines.push(input.paymentRail === "pay-x402" - ? `Budget: ${num(input.budgetUsdc) ?? "—"} USDC · Base Sepolia x402` - : `Budget: ${num(input.budgetDem) ?? "—"} DEM`); - lines.push(`Files: ${arr(input.files).map((file) => str(rec(file).path) || "(unnamed)").join(", ") || "none"}`); - break; - } - case "oracle-desk": { - lines.push(`Product: ${str(input.product)}`); - const params = rec(input.params); - lines.push(`Params: ${Object.entries(params).map(([key, value]) => `${key}=${String(value)}`).join(", ") || "none"}`); - lines.push(`Payment: ${input.paymentRail === "pay-x402" ? "USDC · Base Sepolia x402" : "DEM · Demos"}`); - break; - } - case "dd-researcher": - lines.push(`Subject: ${str(input.subject)} (${str(input.kind)})`); - lines.push(`Payment: ${input.paymentRail === "pay-x402" ? "USDC · Base Sepolia x402" : "DEM · Demos"}`); - break; - case "dep-upgrade": { - const dependencies = rec(rec(input.packageJson).dependencies); - lines.push(`package.json: ${Object.keys(dependencies).length} dependencies`); - lines.push(`Include next major: ${input.includeNextMajor === true ? "yes" : "no"}`); - break; - } - case "evalbot": { - const rubric = rec(input.rubric); - lines.push(`Criteria: ${arr(rubric.criteria).length} · threshold ${num(rubric.acceptThreshold) ?? "—"}`); - lines.push(`Deliverable: ${str(rec(input.deliverable).content).length} chars`); - break; - } - case "treasury-ops": { - const policy = rec(input.policy); - lines.push(`Accounts: ${arr(policy.accounts).map((account) => str(rec(account).id)).join(", ")}`); - lines.push(`Caps: ${num(policy.maxPerTransfer) ?? "—"} per transfer · ${num(policy.maxPerRun) ?? "—"} per run`); - break; - } - case "site-auditor": - lines.push(`URL: ${str(input.url)}`); - lines.push(`Samples: ${num(input.samples) ?? "—"}`); - break; - case "sec-audit": - lines.push(`Files: ${arr(input.files).map((file) => str(rec(file).path) || "(unnamed)").join(", ")}`); - lines.push(`package.json: ${input.packageJson === undefined ? "not provided" : "provided"}`); - break; - case "compliance": - lines.push(`Kind: ${str(input.kind)}`); - lines.push(input.kind === "wallet" ? `Wallet: ${str(input.walletAddress)}` : `Name: ${str(input.name)}`); - lines.push(`Country: ${str(input.country)}${arr(input.aliases).length ? ` · aliases: ${arr(input.aliases).join(", ")}` : ""}`); - break; - default: - lines.push(`${Object.keys(input).length} fields`); - } - return lines; -} - -/** One-line safety statement rendered with each form (honest boundaries). */ -export function agentSafetyNote(name: string, paymentRail: PaymentRail = "pay-dem"): string | null { - switch (name) { - case "procurement-butler": - return paymentRail === "pay-x402" - ? "Runs the full live purchase flow with a real Base Sepolia USDC payment. Your USDC budget is a hard spend cap." - : "Runs the full live purchase flow with a real DEM payment. Your DEM budget is a hard spend cap."; - case "dep-upgrade": - return "Plans upgrades only — it never installs or modifies anything."; - case "treasury-ops": - return "Plans and approves transfers against your policy — it never moves funds."; - case "sec-audit": - return "Scans only the content you paste here. It has no access to your filesystem."; - case "site-auditor": - return "Fetches the public URL you provide, up to the sample count."; - default: - return null; - } -} diff --git a/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts b/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts deleted file mode 100644 index 5808e69..0000000 --- a/reference-implementations/dacs-directory/src/components/try-dacs-stages.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { ProcurementEvent } from "./try-dacs-contract.js"; - -/** - * Pure helpers behind the /try DACS-stage rail: mapping live gateway - * procurement events onto the five DACS stages, computing fail-closed - * progress, and (de)serialising the persisted procurement run record. - */ - -/** Gateway procurement event phases → DACS stage index (0..4). */ -export const PROCUREMENT_PHASE_STAGE: Record = { - queued: 0, connecting: 0, discovering: 0, - selecting: 1, - agreeing: 2, - settling: 3, delivering: 3, recovering: 3, - verifying: 4, evaluating: 4, complete: 4, -}; - -/** - * Some gateway phases span two DACS stages (e.g. the RFQ opens and the - * agreement anchors while the job phase is still "selecting"), so the event - * label can override the phase for known-labelled milestones. - */ -function stageForLabel(label: string): number | undefined { - const lowered = label.toLowerCase(); - if (/\bvet\b/.test(lowered)) return 1; - if (/agreement|commitment|rfq|agreed/.test(lowered)) return 2; - if (/settlement evidence|payment evidence/.test(lowered)) return 3; - return undefined; -} - -export type StagedEvents = { - /** Events grouped by DACS stage index 0..4. */ - byStage: ProcurementEvent[][]; - /** Highest stage the run actually reached — never advanced by failures. */ - progress: number; -}; - -/** - * Assign each event to a DACS stage and compute how far the run actually - * progressed. Fails closed: a terminal "failed" event (or any unknown phase) - * attaches to the stage the run had already reached and NEVER advances - * progress — its error text is not label-matched, so a failure message that - * happens to mention "agreement" cannot fake Negotiate progress. - */ -export function stageEvents(events: readonly ProcurementEvent[]): StagedEvents { - const byStage: ProcurementEvent[][] = [[], [], [], [], []]; - let progress = 0; - for (const event of events) { - const phaseStage = PROCUREMENT_PHASE_STAGE[event.phase]; - // Terminal or UNKNOWN phases attach to the stage the run had reached and - // never advance progress — and their labels are never matched, so an - // unrecognised event whose text mentions "agreement"/"rfq" cannot fake - // Negotiate progress. Label overrides apply to recognised phases only. - if (event.phase === "failed" || phaseStage === undefined) { - byStage[progress]!.push(event); - continue; - } - const stage = stageForLabel(event.label) ?? phaseStage; - byStage[stage]!.push(event); - if (stage > progress) progress = stage; - } - return { byStage, progress }; -} - -/** - * The procurement run record persisted to localStorage so a reload cannot - * lose the idempotency key of a dispatched purchase. Kept until the job - * reaches a terminal state the browser has verified as safe (complete, or - * failed with the gateway's explicit failedBeforePayment=true). - */ -export type StoredProcurementRun = { - runId: string; - jobId?: string; - goal: string; - input: Record; - startedAt: string; -}; - -/** Parse a persisted run record; null for anything malformed. */ -export function parseStoredProcurementRun(raw: string | null): StoredProcurementRun | null { - if (!raw) return null; - try { - const value: unknown = JSON.parse(raw); - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const run = value as Record; - if (typeof run.runId !== "string" || !run.runId.trim()) return null; - if (typeof run.goal !== "string") return null; - if (typeof run.startedAt !== "string") return null; - if (!run.input || typeof run.input !== "object" || Array.isArray(run.input)) return null; - if (run.jobId !== undefined && typeof run.jobId !== "string") return null; - return { - runId: run.runId, - jobId: typeof run.jobId === "string" ? run.jobId : undefined, - goal: run.goal, - input: run.input as Record, - startedAt: run.startedAt, - }; - } catch { - return null; - } -} - -/** Minimal surface of the Web Locks manager the dispatch section needs. */ -export type LockRequestor = { - request: (name: string, options: { signal?: AbortSignal }, callback: () => Promise) => Promise; -}; - -export class ProcurementLockUnavailableError extends Error { - constructor() { - super("This browser does not support the cross-tab locking (Web Locks) required to dispatch a paid procurement safely."); - this.name = "ProcurementLockUnavailableError"; - } -} - -export const PROCUREMENT_LOCK_NAME = "dacs-try:procurement-dispatch"; - -/** - * Run `section` under the cross-tab exclusive procurement lock. REFUSES to - * run when no real lock manager exists — paid dispatch must never fall back - * to best-effort mutual exclusion, because that restores the two-tab - * overwrite race. `signal` aborts a request still WAITING for the lock, so a - * cancelled tab can never execute its section later and write a stale - * record; a section already granted runs to completion (its fetches observe - * the same signal). - */ -export function withExclusiveProcurementLock( - locks: LockRequestor | undefined, - signal: AbortSignal, - section: () => Promise, -): Promise { - if (!locks?.request) return Promise.reject(new ProcurementLockUnavailableError()); - return locks.request(PROCUREMENT_LOCK_NAME, { signal }, section) as Promise; -} - -/** - * What a queued resume section may do once it finally holds the lock. The - * lock only SERIALIZES sections — it cannot stop a stale section from - * executing later — so the section must re-validate the record it captured - * against what storage holds NOW: - * - record gone or owned by a different run → the captured run was - * reconciled elsewhere (dismissed, or replaced by a new purchase); acting - * 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. - */ -export type ResumeDispatchDecision = - | { action: "abort-stale" } - | { action: "read"; jobId: string } - | { action: "post" }; - -export function resumeDispatchDecision( - captured: StoredProcurementRun, - current: StoredProcurementRun | null, -): ResumeDispatchDecision { - if (!current || current.runId !== captured.runId) return { action: "abort-stale" }; - if (current.jobId) return { action: "read", jobId: current.jobId }; - return { action: "post" }; -} diff --git a/reference-implementations/dacs-directory/src/components/try-forms/AgentInputForm.tsx b/reference-implementations/dacs-directory/src/components/try-forms/AgentInputForm.tsx deleted file mode 100644 index b1cd512..0000000 --- a/reference-implementations/dacs-directory/src/components/try-forms/AgentInputForm.tsx +++ /dev/null @@ -1,527 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import type { AgentCard } from "../try-dacs-contract.js"; -import { - ACCOUNT_KINDS, - COMPLIANCE_KINDS, - DD_KINDS, - ORACLE_PRODUCTS, - addRow, - agentSafetyNote, - complianceKindChange, - hasBuiltinForm, - oracleProductChange, - parseAgentFieldSchema, - removeRow, - type AgentFieldSchema, - type FieldErrors, - type SourceFile, -} from "../try-dacs-forms.js"; -import FieldRow from "./FieldRow.js"; -import JsonObjectEditor from "./JsonObjectEditor.js"; -import RubricEditor from "./RubricEditor.js"; -import SourceFilesEditor from "./SourceFilesEditor.js"; - -const rec = (value: unknown): Record => - value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const arr = (value: unknown): unknown[] => Array.isArray(value) ? value : []; -const files = (value: unknown): SourceFile[] => - arr(value).map((file) => ({ path: String(rec(file).path ?? ""), content: String(rec(file).content ?? "") })); - -export type AgentInputFormProps = { - agent: AgentCard; - value: Record; - onChange: (next: Record) => void; - errors: FieldErrors; - gatewayErrors: FieldErrors; -}; - -/** - * The human-friendly input form for a published agent. Prefers a catalog- - * published field schema when the gateway provides one; falls back to the - * built-in per-agent layout; unknown agents fall back to the Advanced JSON - * editor alone. All controls edit the same gateway input object that - * `runAgent()` submits unchanged. - */ -export default function AgentInputForm(props: AgentInputFormProps) { - const { agent, value } = props; - const schema = parseAgentFieldSchema(agent); - const paymentRail = value.paymentRail === "pay-x402" ? "pay-x402" : "pay-dem"; - const note = agentSafetyNote(agent.name, paymentRail); - // Precedence: the nine bespoke forms know their agents best; the gateway's - // published input schema renders any NEW agent; JSON is the last resort. - const builtin = hasBuiltinForm(agent.name); - return ( -
- {note &&

{note}

} - {builtin - ? - : schema - ? - :

This agent has not published a form schema yet — use the advanced JSON input below.

} - -
- ); -} - -/** Generic renderer for a future catalog-published field schema. */ -function SchemaForm({ schema, value, onChange, errors, gatewayErrors }: AgentInputFormProps & { schema: AgentFieldSchema[] }) { - return ( -
- {schema.map((field) => { - const id = `schema-${field.key}`; - const current = value[field.key]; - return ( - - {field.kind === "checkbox" ? ( - onChange({ ...value, [field.key]: event.target.checked })} /> - ) : field.kind === "select" ? ( - - ) : field.kind === "textarea" ? ( -