Files
agents-marketplace/supplier/src/index.ts
david 2f7b0194a5 feat(supplier,gateway,buyer): N-slot chat suppliers + entry-ticket settle mode
Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):

Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
  free-until-full status semantics, ref-scoped release(ref) (also fixes the
  latent bug where handler catch-alls released another request's slot), and
  a walletMutex serializing every wallet-spending chain op — Claim, Submit,
  and wallet-health consolidation queue instead of racing the single
  working/collateral UTxO. /status gains active_sessions/max_sessions.

Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
  verifies the Open escrow as an entry ticket but never Claims/Submits
  (/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
  {status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
  Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
  supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
  after deliver_by WITHOUT closing live sessions (the conversation and its
  affinity mirror survive their reclaimed ticket), gates reclaims on
  deliver_by, and keeps an in-process settled-refs memory so terminal
  indexer rows aren't retried every sweep forever.

Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:48:52 +02:00

102 lines
3.8 KiB
TypeScript

/**
* supplier/src/index.ts — Supplier-node entry point.
*
* Loads config from process.env, instantiates state + chain provider,
* boots the Express app, and installs graceful shutdown.
*
* Run:
* node --experimental-strip-types supplier/src/index.ts
* (or via tsx for dev)
*
* Environment variables: see ./config.ts.
*
* For M1-C the chain provider here defaults to the read-only Ogmios
* provider — it can read UTxOs and tip but not submit. Tier 3 (live)
* wiring lands in M1-D when tx-submit harness is ready. Tests bypass
* this entry point entirely and call createApp(deps) directly.
*/
import { loadConfig } from "./config.js";
import { SupplierState } from "./state.js";
import { JobStore } from "./jobs.js";
import { createApp, type SupplierDeps } from "./server.js";
import { buildChainProvider } from "./chain.js";
import { startWalletHealthTicker } from "./walletHealth.js";
async function main(): Promise<void> {
const cfg = loadConfig(process.env);
// Derive a WalletKey from the configured private key. v1 receipt-signing
// uses the same key as on-chain signing — see ARCHITECTURE.md §9 open
// follow-up (sub-keys are a future tightening, not v1).
// Public key + pkh are NOT derived here yet: M1-D will plug in the proper
// Ed25519 + blake2b-224 pipeline. For boot correctness today we read them
// from optional env vars; tests inject WalletKey directly.
const supplierKey = {
privateKeyHex: cfg.supplierPrivKeyHex,
pubKeyHex: process.env.SUPPLIER_PUB_KEY_HEX ?? "",
pubKeyHash: process.env.SUPPLIER_PKH ?? "",
address: process.env.SUPPLIER_ADDRESS ?? "",
};
// M1-F-2: provider selection is driven by cfg.liveChain (LIVE_CHAIN=1).
// ReadOnly is the safe default; live mode is explicit opt-in.
const chain = buildChainProvider(cfg);
// chat-session kind serves N concurrent sessions; one-shot kinds stay 1.
const state = new SupplierState(cfg.capabilityKind === "chat-session" ? cfg.maxChatSessions : 1);
const jobs = new JobStore();
const deps: SupplierDeps = { chain, state, config: cfg, supplierKey, jobs };
const app = createApp(deps);
const server = app.listen(cfg.port, () => {
// eslint-disable-next-line no-console
console.log(`supplier listening on :${cfg.port}`);
});
// M1-F-async-chat: evict expired terminal jobs every 60s (TTL = 10min).
// .unref() prevents the timer from keeping the event loop alive after
// server.close() during shutdown.
const evictInterval = setInterval(
() => jobs.evictExpired(Date.now()),
60_000,
);
evictInterval.unref();
// Wallet self-consolidation: only meaningful when chain writes are enabled.
// 0 disables. planConsolidate short-circuits to already-healthy in the
// common case so this is a no-op on a clean wallet.
const walletHealthTicker =
cfg.liveChain && cfg.walletHealthIntervalMs > 0
? startWalletHealthTicker(
{ chain, state, supplierKey },
{ intervalMs: cfg.walletHealthIntervalMs },
)
: null;
const shutdown = (signal: string) => {
// eslint-disable-next-line no-console
console.log(`received ${signal}, shutting down`);
clearInterval(evictInterval);
walletHealthTicker?.stop();
state.markOffline();
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5_000).unref();
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
// Only run when executed directly (not when imported by tests).
const isDirectRun = process.argv[1]?.endsWith("supplier/src/index.ts")
|| process.argv[1]?.endsWith("supplier/dist/index.js");
if (isDirectRun) {
main().catch((err) => {
// eslint-disable-next-line no-console
console.error("supplier boot failed:", err);
process.exit(1);
});
}
export { main };