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>
This commit is contained in:
@@ -31,6 +31,8 @@ export function buildSampleConfig(): SupplierConfig {
|
||||
piperUrl: "",
|
||||
piperTimeoutMs: 120_000,
|
||||
capabilityKind: "chat",
|
||||
maxChatSessions: 1,
|
||||
chatSettleMode: "full",
|
||||
liveChain: false,
|
||||
};
|
||||
}
|
||||
|
||||
118
tests/unit/buyer-sdk-chat-session.test.ts
Normal file
118
tests/unit/buyer-sdk-chat-session.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* buyer-sdk-chat-session.test.ts — settle-mode handling in startChat/endChat.
|
||||
*
|
||||
* Ticket-mode suppliers answer /v1/chat/start with {status:"ticket"} and
|
||||
* /v1/chat/end with {status:"ended", settle_mode:"ticket"}. The SDK must map
|
||||
* those to settleMode:"ticket" — and, at end, skip receipt verification AND
|
||||
* the Accept tx entirely (zero chain calls). Full-shaped responses keep the
|
||||
* existing behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { Marketplace } from "../../buyer/src/sdk/Marketplace.js";
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
|
||||
import type { AdvertDatum } from "../../packages/shared/src/cbor/types.js";
|
||||
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
|
||||
|
||||
const ADVERT_TX_HASH = "b".repeat(64);
|
||||
const ESCROW_REF = { txHash: "f".repeat(64), index: 0 };
|
||||
const ESCROW_REF_STR = `${ESCROW_REF.txHash}#0`;
|
||||
|
||||
function advertDatum(): AdvertDatum {
|
||||
return {
|
||||
supplier_pkh: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef01",
|
||||
capability_id: "llm.chat.v1",
|
||||
model: "kimi",
|
||||
max_output_tokens: 512,
|
||||
max_processing_ms: 1_800_000,
|
||||
price_lovelace: 200_000n,
|
||||
supplier_bond_lovelace: 1_000_000n,
|
||||
buyer_bond_lovelace: 1_000_000n,
|
||||
endpoint_url: "http://supplier.test",
|
||||
detail_uri: "ipfs://QmTest",
|
||||
detail_hash: "a".repeat(64),
|
||||
advertised_at: 1_745_500_000_000,
|
||||
status: "Active",
|
||||
};
|
||||
}
|
||||
|
||||
/** fetch stub answering the supplier's /status, /v1/chat/start, /v1/chat/end. */
|
||||
function supplierFetch(responses: { start?: unknown; end?: unknown }) {
|
||||
return vi.fn(async (url: RequestInfo | URL) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/status")) {
|
||||
return new Response(JSON.stringify({ status: "free" }), { status: 200 });
|
||||
}
|
||||
if (u.includes("/v1/chat/start")) {
|
||||
return new Response(JSON.stringify(responses.start ?? { status: "claimed" }), { status: 200 });
|
||||
}
|
||||
if (u.includes("/v1/chat/end")) {
|
||||
return new Response(JSON.stringify(responses.end ?? {}), { status: 200 });
|
||||
}
|
||||
if (u.includes("/capability")) {
|
||||
return new Response(JSON.stringify({ supplier_pkh: "", model: "kimi" }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
function makeMp(fetchImpl: typeof globalThis.fetch, chain = new MockChainProvider()) {
|
||||
chain.seed({
|
||||
ref: { txHash: ADVERT_TX_HASH, index: 0 },
|
||||
address: "addr_test1wfakeadvert",
|
||||
lovelace: 2_000_000n,
|
||||
assets: {},
|
||||
datumHex: encodeAdvertDatum(advertDatum()),
|
||||
scriptRef: null,
|
||||
});
|
||||
return { mp: new Marketplace({
|
||||
chain,
|
||||
indexerUrl: "http://indexer.test",
|
||||
walletKey: buildBuyerWalletKey(),
|
||||
networkParams: { networkId: 0 },
|
||||
_fetch: fetchImpl,
|
||||
}), chain };
|
||||
}
|
||||
|
||||
describe("Marketplace.startChat — settle-mode mapping", () => {
|
||||
it("maps {status:'ticket'} to settleMode ticket", async () => {
|
||||
const { mp } = makeMp(supplierFetch({ start: { status: "ticket", settle_mode: "ticket" } }));
|
||||
const result = await mp.startChat({ advertRef: { txHash: ADVERT_TX_HASH, index: 0 }, payment_lovelace: 200_000n });
|
||||
expect(result.settleMode).toBe("ticket");
|
||||
});
|
||||
|
||||
it("maps {status:'claimed'} (and legacy responses) to settleMode full", async () => {
|
||||
const { mp } = makeMp(supplierFetch({ start: { status: "claimed" } }));
|
||||
const result = await mp.startChat({ advertRef: { txHash: ADVERT_TX_HASH, index: 0 }, payment_lovelace: 200_000n });
|
||||
expect(result.settleMode).toBe("full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Marketplace.endChat — ticket branch", () => {
|
||||
it("returns the ticket result with zero chain calls and no receipt verification", async () => {
|
||||
const chain = new MockChainProvider();
|
||||
const submitSpy = vi.spyOn(chain, "submitTx");
|
||||
const { mp } = makeMp(
|
||||
supplierFetch({ end: { status: "ended", escrow_ref: ESCROW_REF_STR, settle_mode: "ticket" } }),
|
||||
chain,
|
||||
);
|
||||
|
||||
const result = await mp.endChat({
|
||||
escrowRef: ESCROW_REF,
|
||||
sessionNonce: "nonce-abc",
|
||||
supplierBaseUrl: "http://supplier.test",
|
||||
});
|
||||
|
||||
expect(result.settleMode).toBe("ticket");
|
||||
expect("receipt" in result).toBe(false);
|
||||
expect(submitSpy).not.toHaveBeenCalled(); // no Accept tx
|
||||
});
|
||||
|
||||
it("still rejects a receipt-less response that does not declare ticket mode", async () => {
|
||||
const { mp } = makeMp(supplierFetch({ end: { status: "submitted" } }));
|
||||
await expect(
|
||||
mp.endChat({ escrowRef: ESCROW_REF, sessionNonce: "nonce-abc", supplierBaseUrl: "http://supplier.test" }),
|
||||
).rejects.toThrow(/receipt/);
|
||||
});
|
||||
});
|
||||
277
tests/unit/supplier-chat-session-ticket.test.ts
Normal file
277
tests/unit/supplier-chat-session-ticket.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* supplier-chat-session-ticket.test.ts — entry-ticket settle mode + N slots.
|
||||
*
|
||||
* Ticket mode (CHAT_SETTLE_MODE=ticket): /v1/chat/start verifies the Open
|
||||
* escrow but never Claims (zero chain txs), arms no hard-cap timer, and
|
||||
* /v1/chat/end returns a receipt-less {status:"ended"}. A used escrow ref can
|
||||
* never open a second session (records are retained). MAX_CHAT_SESSIONS
|
||||
* admits N concurrent sessions and the N+1th start gets 409 supplier_busy.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createHash } from "crypto";
|
||||
import type { Application } from "express";
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
|
||||
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
|
||||
import { encodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
|
||||
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
|
||||
import { chatSessionPromptHash } from "../../packages/shared/src/tx/escrow/postChatEscrow.js";
|
||||
import type { SupplierConfig } from "../../supplier/src/config.js";
|
||||
import { SupplierState } from "../../supplier/src/state.js";
|
||||
import { JobStore } from "../../supplier/src/jobs.js";
|
||||
import { ChatSessionStore } from "../../supplier/src/chatSession.js";
|
||||
import { createApp } from "../../supplier/src/server.js";
|
||||
import { buildSampleConfig, SAMPLE_ADVERT_TX_HASH, SAMPLE_ADVERT_INDEX } from "../fixtures/supplier-side/sample-config.js";
|
||||
import { buildSupplierWalletKey, SUPPLIER_PKH } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
|
||||
const MODEL = "kimi";
|
||||
const MAX_TOKENS = 512;
|
||||
const CAPABILITY = "llm.chat.v1";
|
||||
const NONCE = "nonce-abc";
|
||||
const POSTED_AT = 1_745_500_000_000;
|
||||
const DELIVER_BY = POSTED_AT + 90_000;
|
||||
|
||||
function sha256Hex(s: string): string {
|
||||
return createHash("sha256").update(s, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function advertDatum(): AdvertDatum {
|
||||
return {
|
||||
supplier_pkh: SUPPLIER_PKH,
|
||||
capability_id: CAPABILITY,
|
||||
model: MODEL,
|
||||
max_output_tokens: MAX_TOKENS,
|
||||
max_processing_ms: 1_800_000,
|
||||
price_lovelace: 200_000n,
|
||||
supplier_bond_lovelace: 1_000_000n,
|
||||
buyer_bond_lovelace: 1_000_000n,
|
||||
endpoint_url: "http://supplier.example:8080",
|
||||
detail_uri: "ipfs://QmTest",
|
||||
detail_hash: "a".repeat(64),
|
||||
advertised_at: POSTED_AT,
|
||||
status: "Active",
|
||||
};
|
||||
}
|
||||
|
||||
function escrowDatum(nonce: string): EscrowDatum {
|
||||
return {
|
||||
buyer_pkh: "1234567890abcdef1234567890abcdef1234567890abcdef12345678",
|
||||
supplier_pkh: SUPPLIER_PKH,
|
||||
advert_ref: { txHash: SAMPLE_ADVERT_TX_HASH, index: SAMPLE_ADVERT_INDEX },
|
||||
capability_id: CAPABILITY,
|
||||
request_spec_hash: sha256Hex(canonicalize({ capability_id: CAPABILITY, max_output_tokens: MAX_TOKENS, model: MODEL })),
|
||||
prompt_hash: chatSessionPromptHash({ session_nonce: nonce }),
|
||||
payment_lovelace: 200_000n,
|
||||
buyer_bond_lovelace: 1_000_000n,
|
||||
supplier_bond_lovelace: 1_000_000n,
|
||||
deliver_by: DELIVER_BY,
|
||||
posted_at: POSTED_AT,
|
||||
submitted_at: null,
|
||||
result_receipt_hash: null,
|
||||
state: "Open",
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
app: Application;
|
||||
chain: MockChainProvider;
|
||||
state: SupplierState;
|
||||
chatSessions: ChatSessionStore;
|
||||
seedEscrow: (txHashChar: string, nonce?: string) => string;
|
||||
}
|
||||
|
||||
function makeHarness(overrides?: Partial<SupplierConfig>): Harness {
|
||||
const chain = new MockChainProvider();
|
||||
chain.seed({
|
||||
ref: { txHash: SAMPLE_ADVERT_TX_HASH, index: SAMPLE_ADVERT_INDEX },
|
||||
address: "addr_test1wfakeadvertaddress",
|
||||
lovelace: 2_000_000n,
|
||||
assets: {},
|
||||
datumHex: encodeAdvertDatum(advertDatum()),
|
||||
scriptRef: null,
|
||||
});
|
||||
const config: SupplierConfig = {
|
||||
...buildSampleConfig(),
|
||||
capabilityKind: "chat-session",
|
||||
llmBackend: "openai",
|
||||
ollamaUrl: "",
|
||||
openaiBaseUrl: "http://up",
|
||||
openaiApiKey: "",
|
||||
openaiTimeoutMs: 5_000,
|
||||
openaiMaxTokens: 0,
|
||||
openaiReasoningDisabled: false,
|
||||
chatIdleTimeoutMs: 60_000,
|
||||
chatSettleMode: "ticket",
|
||||
maxChatSessions: 1,
|
||||
...overrides,
|
||||
} as SupplierConfig;
|
||||
const state = new SupplierState(config.maxChatSessions);
|
||||
const chatSessions = new ChatSessionStore();
|
||||
const app = createApp({
|
||||
chain,
|
||||
state,
|
||||
config,
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
jobs: new JobStore(),
|
||||
chatSessions,
|
||||
});
|
||||
const seedEscrow = (txHashChar: string, nonce = NONCE): string => {
|
||||
const txHash = txHashChar.repeat(64);
|
||||
chain.seed({
|
||||
ref: { txHash, index: 0 },
|
||||
address: "addr_test1wfakeescrowaddress",
|
||||
lovelace: 2_200_000n,
|
||||
assets: {},
|
||||
datumHex: encodeEscrowDatum(escrowDatum(nonce)),
|
||||
scriptRef: null,
|
||||
});
|
||||
return `${txHash}#0`;
|
||||
};
|
||||
return { app, chain, state, chatSessions, seedEscrow };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("chat-session ticket mode — /v1/chat/start", () => {
|
||||
it("verifies the escrow, skips Claim, and arms no hard-cap timer", async () => {
|
||||
const h = makeHarness();
|
||||
const submitSpy = vi.spyOn(h.chain, "submitTx");
|
||||
const ref = h.seedEscrow("e");
|
||||
|
||||
const res = await request(h.app)
|
||||
.post("/v1/chat/start")
|
||||
.set("X-Escrow-Ref", ref)
|
||||
.send({ session_nonce: NONCE });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: "ticket", escrow_ref: ref, settle_mode: "ticket" });
|
||||
expect(submitSpy).not.toHaveBeenCalled(); // zero chain txs
|
||||
|
||||
const record = h.chatSessions.get(ref)!;
|
||||
expect(record.settleMode).toBe("ticket");
|
||||
expect(record.claimedRef).toBeUndefined();
|
||||
expect(record.idleTimer).toBeDefined();
|
||||
expect(record.hardCapTimer).toBeUndefined();
|
||||
expect(h.state.snapshot().activeSessions).toBe(1);
|
||||
});
|
||||
|
||||
it("still rejects a foreign-supplier escrow", async () => {
|
||||
const h = makeHarness();
|
||||
const txHash = "c".repeat(64);
|
||||
h.chain.seed({
|
||||
ref: { txHash, index: 0 },
|
||||
address: "addr_test1wfakeescrowaddress",
|
||||
lovelace: 2_200_000n,
|
||||
assets: {},
|
||||
datumHex: encodeEscrowDatum({ ...escrowDatum(NONCE), supplier_pkh: "9".repeat(56) }),
|
||||
scriptRef: null,
|
||||
});
|
||||
const res = await request(h.app)
|
||||
.post("/v1/chat/start")
|
||||
.set("X-Escrow-Ref", `${txHash}#0`)
|
||||
.send({ session_nonce: NONCE });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.reason).toBe("wrong_supplier");
|
||||
});
|
||||
|
||||
it("rejects reusing an escrow ref while the session is active AND after it ended", async () => {
|
||||
const h = makeHarness();
|
||||
const ref = h.seedEscrow("e");
|
||||
|
||||
const first = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const whileActive = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
|
||||
expect(whileActive.status).toBe(409);
|
||||
expect(whileActive.body.reason).toBe("session_exists");
|
||||
|
||||
await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", ref).send({});
|
||||
// The escrow is still Open on-chain (never Claimed) — the retained record
|
||||
// is the only thing standing between a used ticket and a free session.
|
||||
const afterEnd = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
|
||||
expect(afterEnd.status).toBe(409);
|
||||
expect(afterEnd.body.reason).toBe("session_exists");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat-session ticket mode — /v1/chat/end", () => {
|
||||
it("ends without a receipt, frees the slot, and is idempotent", async () => {
|
||||
const h = makeHarness();
|
||||
const submitSpy = vi.spyOn(h.chain, "submitTx");
|
||||
const ref = h.seedEscrow("e");
|
||||
await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", ref).send({ session_nonce: NONCE });
|
||||
|
||||
const end = await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", ref).send({});
|
||||
expect(end.status).toBe(200);
|
||||
expect(end.body).toEqual({ status: "ended", escrow_ref: ref, settle_mode: "ticket" });
|
||||
expect(end.body.receipt).toBeUndefined();
|
||||
expect(submitSpy).not.toHaveBeenCalled();
|
||||
expect(h.state.snapshot().activeSessions).toBe(0);
|
||||
|
||||
const again = await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", ref).send({});
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body.status).toBe("ended");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat-session slots — MAX_CHAT_SESSIONS", () => {
|
||||
it("admits N concurrent sessions and 409s the N+1th", async () => {
|
||||
const h = makeHarness({ maxChatSessions: 2 });
|
||||
// NB: "b".repeat(64) is the advert tx hash — do not reuse it for escrows.
|
||||
const refA = h.seedEscrow("d");
|
||||
const refB = h.seedEscrow("e");
|
||||
const refC = h.seedEscrow("f");
|
||||
|
||||
const a = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refA).send({ session_nonce: NONCE });
|
||||
const b = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refB).send({ session_nonce: NONCE });
|
||||
expect(a.status).toBe(200);
|
||||
expect(b.status).toBe(200);
|
||||
|
||||
const status = await request(h.app).get("/status");
|
||||
expect(status.body.status).toBe("working");
|
||||
expect(status.body.active_sessions).toBe(2);
|
||||
expect(status.body.max_sessions).toBe(2);
|
||||
|
||||
const c = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refC).send({ session_nonce: NONCE });
|
||||
expect(c.status).toBe(409);
|
||||
expect(c.body.reason).toBe("supplier_busy");
|
||||
|
||||
// Ending one session readmits the third.
|
||||
await request(h.app).post("/v1/chat/end").set("X-Escrow-Ref", refA).send({});
|
||||
const retry = await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refC).send({ session_nonce: NONCE });
|
||||
expect(retry.status).toBe(200);
|
||||
});
|
||||
|
||||
it("serves concurrent message turns on distinct sessions", async () => {
|
||||
const h = makeHarness({ maxChatSessions: 2 });
|
||||
const refA = h.seedEscrow("d");
|
||||
const refB = h.seedEscrow("e");
|
||||
await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refA).send({ session_nonce: NONCE });
|
||||
await request(h.app).post("/v1/chat/start").set("X-Escrow-Ref", refB).send({ session_nonce: NONCE });
|
||||
|
||||
const stream = [
|
||||
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" }, finish_reason: null }] })}\n\n`,
|
||||
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2 } })}\n\n`,
|
||||
"data: [DONE]\n\n",
|
||||
].join("");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async () =>
|
||||
new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } })),
|
||||
);
|
||||
|
||||
const [ta, tb] = await Promise.all([
|
||||
request(h.app).post("/v1/chat/message").set("X-Escrow-Ref", refA).send({ messages: [{ role: "user", content: "hi" }] }),
|
||||
request(h.app).post("/v1/chat/message").set("X-Escrow-Ref", refB).send({ messages: [{ role: "user", content: "yo" }] }),
|
||||
]);
|
||||
expect(ta.text).toContain('"type":"done"');
|
||||
expect(tb.text).toContain('"type":"done"');
|
||||
expect(h.chatSessions.get(refA)!.transcript).toHaveLength(2);
|
||||
expect(h.chatSessions.get(refB)!.transcript).toHaveLength(2);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -218,3 +218,39 @@ describe("parseAdvertRef()", () => {
|
||||
expect(() => parseAdvertRef(`${"a".repeat(64)}#-1`)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig — chat-session concurrency + settle mode ─────────────────────
|
||||
|
||||
describe("loadConfig() — MAX_CHAT_SESSIONS / CHAT_SETTLE_MODE", () => {
|
||||
const chatSessionEnv = () => ({
|
||||
...buildSampleEnv(),
|
||||
CAPABILITY_KIND: "chat-session",
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "http://up",
|
||||
});
|
||||
|
||||
it("defaults to 1 slot and full settle mode", () => {
|
||||
const cfg = loadConfig(buildSampleEnv());
|
||||
expect(cfg.maxChatSessions).toBe(1);
|
||||
expect(cfg.chatSettleMode).toBe("full");
|
||||
});
|
||||
|
||||
it("parses MAX_CHAT_SESSIONS and CHAT_SETTLE_MODE for chat-session kind", () => {
|
||||
const cfg = loadConfig({ ...chatSessionEnv(), MAX_CHAT_SESSIONS: "8", CHAT_SETTLE_MODE: "ticket" });
|
||||
expect(cfg.maxChatSessions).toBe(8);
|
||||
expect(cfg.chatSettleMode).toBe("ticket");
|
||||
});
|
||||
|
||||
it("rejects MAX_CHAT_SESSIONS > 1 for non-chat-session kinds", () => {
|
||||
expect(() => loadConfig({ ...buildSampleEnv(), MAX_CHAT_SESSIONS: "4" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects CHAT_SETTLE_MODE=ticket for non-chat-session kinds", () => {
|
||||
expect(() => loadConfig({ ...buildSampleEnv(), CHAT_SETTLE_MODE: "ticket" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid values", () => {
|
||||
expect(() => loadConfig({ ...chatSessionEnv(), MAX_CHAT_SESSIONS: "0" })).toThrow();
|
||||
expect(() => loadConfig({ ...chatSessionEnv(), CHAT_SETTLE_MODE: "nope" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,25 @@ describe("GET /status — response body when offline", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /status — session slot fields", () => {
|
||||
it("reports active_sessions/max_sessions when free", async () => {
|
||||
const state = new SupplierState();
|
||||
const res = await request(makeApp(state)).get("/status");
|
||||
expect(res.body.active_sessions).toBe(0);
|
||||
expect(res.body.max_sessions).toBe(1);
|
||||
});
|
||||
|
||||
it("stays free with slots remaining and reports the active count", async () => {
|
||||
const state = new SupplierState(3);
|
||||
state.tryAcquire(ESCROW_REF);
|
||||
const res = await request(makeApp(state)).get("/status");
|
||||
expect(res.body.status).toBe("free");
|
||||
expect(res.body.active_sessions).toBe(1);
|
||||
expect(res.body.max_sessions).toBe(3);
|
||||
expect(res.body.current_escrow_ref).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /status — does not touch chain", () => {
|
||||
it("returns 200 even when chain has no UTxOs seeded", async () => {
|
||||
// If the handler incorrectly touches chain, it would throw because
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* supplier-state.test.ts — RED phase tests for supplier/src/state.ts
|
||||
* supplier-state.test.ts — tests for supplier/src/state.ts
|
||||
*
|
||||
* Tests the SupplierState in-process slot lock:
|
||||
* - tryAcquire(escrowRef): boolean — atomic test-and-set
|
||||
* - release()
|
||||
* Tests the SupplierState session-slot admission (capacity-aware) + the
|
||||
* wallet mutex:
|
||||
* - tryAcquire(escrowRef): boolean — slot admission (default capacity 1)
|
||||
* - release(escrowRef) — frees exactly that ref's slot
|
||||
* - markOffline()
|
||||
* - snapshot(): { status, currentEscrowRef?, lastSeenIso }
|
||||
* - snapshot(): { status, currentEscrowRef?, activeSessions, maxSessions, ... }
|
||||
* - walletMutex.run — strict serialization of wallet chain ops
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
@@ -13,6 +15,7 @@ import { SupplierState } from "../../supplier/src/state.js";
|
||||
|
||||
const ESCROW_REF_A = `${"f".repeat(64)}#0`;
|
||||
const ESCROW_REF_B = `${"e".repeat(64)}#1`;
|
||||
const ESCROW_REF_C = `${"d".repeat(64)}#2`;
|
||||
|
||||
describe("SupplierState — initial state", () => {
|
||||
it("starts in free state", () => {
|
||||
@@ -102,25 +105,25 @@ describe("SupplierState.release()", () => {
|
||||
|
||||
it("transitions working → free", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.release();
|
||||
state.release(ESCROW_REF_A);
|
||||
expect(state.snapshot().status).toBe("free");
|
||||
});
|
||||
|
||||
it("clears currentEscrowRef on release", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.release();
|
||||
state.release(ESCROW_REF_A);
|
||||
expect(state.snapshot().currentEscrowRef).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows re-acquire after release", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.release();
|
||||
state.release(ESCROW_REF_A);
|
||||
expect(state.tryAcquire(ESCROW_REF_B)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-acquire after release sets new currentEscrowRef", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.release();
|
||||
state.release(ESCROW_REF_A);
|
||||
state.tryAcquire(ESCROW_REF_B);
|
||||
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_B);
|
||||
});
|
||||
@@ -128,7 +131,7 @@ describe("SupplierState.release()", () => {
|
||||
it("updates lastSeenIso on release", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
const beforeRelease = state.snapshot().lastSeenIso;
|
||||
state.release();
|
||||
state.release(ESCROW_REF_A);
|
||||
const afterRelease = state.snapshot().lastSeenIso;
|
||||
expect(new Date(afterRelease).getTime()).not.toBeNaN();
|
||||
expect(afterRelease >= beforeRelease).toBe(true);
|
||||
@@ -199,4 +202,94 @@ describe("SupplierState.snapshot()", () => {
|
||||
// State must remain working
|
||||
expect(state.snapshot().status).toBe("working");
|
||||
});
|
||||
|
||||
it("reports active/max session counts", () => {
|
||||
const state = new SupplierState();
|
||||
expect(state.snapshot().activeSessions).toBe(0);
|
||||
expect(state.snapshot().maxSessions).toBe(1);
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
expect(state.snapshot().activeSessions).toBe(1);
|
||||
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF_A]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupplierState — capacity > 1", () => {
|
||||
let state: SupplierState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new SupplierState(2);
|
||||
});
|
||||
|
||||
it("stays free until every slot is taken (free-until-full)", () => {
|
||||
expect(state.tryAcquire(ESCROW_REF_A)).toBe(true);
|
||||
expect(state.snapshot().status).toBe("free");
|
||||
expect(state.snapshot().activeSessions).toBe(1);
|
||||
expect("currentEscrowRef" in state.snapshot()).toBe(false);
|
||||
|
||||
expect(state.tryAcquire(ESCROW_REF_B)).toBe(true);
|
||||
expect(state.snapshot().status).toBe("working");
|
||||
expect(state.snapshot().currentEscrowRef).toBe(ESCROW_REF_A); // oldest
|
||||
});
|
||||
|
||||
it("rejects the N+1th acquire", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.tryAcquire(ESCROW_REF_B);
|
||||
expect(state.tryAcquire(ESCROW_REF_C)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a duplicate ref while held", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
expect(state.tryAcquire(ESCROW_REF_A)).toBe(false);
|
||||
});
|
||||
|
||||
it("release frees exactly that ref's slot", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.tryAcquire(ESCROW_REF_B);
|
||||
state.release(ESCROW_REF_A);
|
||||
expect(state.snapshot().status).toBe("free");
|
||||
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF_B]);
|
||||
expect(state.tryAcquire(ESCROW_REF_C)).toBe(true);
|
||||
});
|
||||
|
||||
it("release of an unheld ref is a no-op", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.release(ESCROW_REF_B);
|
||||
expect(state.snapshot().activeSessions).toBe(1);
|
||||
});
|
||||
|
||||
it("markOffline clears all slots and refuses new acquires", () => {
|
||||
state.tryAcquire(ESCROW_REF_A);
|
||||
state.tryAcquire(ESCROW_REF_B);
|
||||
state.markOffline();
|
||||
expect(state.snapshot().status).toBe("offline");
|
||||
expect(state.snapshot().activeSessions).toBe(0);
|
||||
expect(state.tryAcquire(ESCROW_REF_C)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-positive capacity", () => {
|
||||
expect(() => new SupplierState(0)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupplierState.walletMutex", () => {
|
||||
it("serializes runs in submission order and survives rejection", async () => {
|
||||
const state = new SupplierState(4);
|
||||
const order: number[] = [];
|
||||
const p1 = state.walletMutex.run(async () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
order.push(1);
|
||||
});
|
||||
const p2 = state.walletMutex.run(async () => {
|
||||
order.push(2);
|
||||
});
|
||||
await Promise.all([p1, p2]);
|
||||
expect(order).toEqual([1, 2]);
|
||||
|
||||
await state.walletMutex
|
||||
.run(async () => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
.catch(() => undefined);
|
||||
expect(await state.walletMutex.run(async () => 42)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
*
|
||||
* Coverage:
|
||||
* - Ticker calls consolidate when idle, logs already-healthy
|
||||
* - Ticker skips when the supplier state is busy
|
||||
* - Ticker releases the lock around runConsolidateWallet (success + failure)
|
||||
* - Consolidation runs even while session slots are held (wallet ops don't
|
||||
* consume session slots) but queues behind a held walletMutex
|
||||
* - Failure paths never leave the walletMutex poisoned
|
||||
* - On-failure trigger fires consolidate the first time
|
||||
* - On-failure trigger debounces back-to-back invocations
|
||||
* - On-failure trigger respects the state lock
|
||||
*
|
||||
* The consolidate function is injected via deps so the test never touches
|
||||
* a real Ogmios.
|
||||
@@ -91,7 +91,7 @@ describe("walletHealth — ticker", () => {
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("skips when supplier state is busy", async () => {
|
||||
it("runs even while a session slot is held (wallet ops don't consume slots)", async () => {
|
||||
state.tryAcquire(ESCROW_REF);
|
||||
consolidate.mockResolvedValue(healthyResult);
|
||||
|
||||
@@ -101,16 +101,39 @@ describe("walletHealth — ticker", () => {
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(consolidate).not.toHaveBeenCalled();
|
||||
expect(logLines).toContain("skip: supplier busy");
|
||||
// The held session slot is untouched.
|
||||
expect(state.snapshot().status).toBe("working");
|
||||
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF]);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("releases the lock after a successful consolidate", async () => {
|
||||
it("queues behind a held walletMutex instead of racing it", async () => {
|
||||
consolidate.mockResolvedValue(healthyResult);
|
||||
let releaseChainOp: () => void = () => undefined;
|
||||
const chainOp = state.walletMutex.run(
|
||||
() => new Promise<void>((resolve) => { releaseChainOp = resolve; }),
|
||||
);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
// The mutex is held by the fake chain op — consolidate must wait.
|
||||
expect(consolidate).not.toHaveBeenCalled();
|
||||
|
||||
releaseChainOp();
|
||||
await chainOp;
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("logs success after a real consolidate", async () => {
|
||||
consolidate.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
@@ -127,7 +150,7 @@ describe("walletHealth — ticker", () => {
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("releases the lock when consolidate throws", async () => {
|
||||
it("does not poison the walletMutex when consolidate throws", async () => {
|
||||
consolidate.mockRejectedValue(new Error("ogmios unreachable"));
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
@@ -138,8 +161,9 @@ describe("walletHealth — ticker", () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(state.snapshot().status).toBe("free");
|
||||
expect(logLines.some((l) => l.includes("consolidate failed"))).toBe(true);
|
||||
// The mutex still serves the next wallet op.
|
||||
expect(await state.walletMutex.run(async () => "ok")).toBe("ok");
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
@@ -202,7 +226,7 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
|
||||
expect(consolidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not throw when supplier is busy — just skips", async () => {
|
||||
it("runs even when session slots are held (queues on the wallet mutex)", async () => {
|
||||
state.tryAcquire(ESCROW_REF);
|
||||
consolidate.mockResolvedValue(healthyResult);
|
||||
|
||||
@@ -213,9 +237,8 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
await Promise.resolve();
|
||||
expect(consolidate).not.toHaveBeenCalled();
|
||||
expect(logLines).toContain("skip: supplier busy");
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
||||
// The held session slot is untouched.
|
||||
expect(state.snapshot().status).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user