Files
agents-marketplace/tests/unit/supplier-job-runner.test.ts
jelisaveta-talbot cbab81efbd M1-F: end-to-end LLM marketplace lifecycle on Vector testnet
Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.

Workspace layout (pnpm + TypeScript):
  contracts/marketplace/      Aiken validators (advert + escrow) + plutus.json
  packages/shared/            Tx-builders (lucid-evolution), CBOR codecs,
                              chain providers (mock / read-only / live Ogmios)
  supplier/                   OpenAI-compatible HTTP node (Ollama by default;
                              vLLM / llama.cpp / OpenRouter / OpenClaw all
                              swap-in via OLLAMA_URL); 202+poll chat handler
  buyer/                      Express + React SPA; server-side runs the full
                              chain lifecycle via /v1/submit-prompt and
                              /v1/accept; SPA never holds the buyer key
  indexer/                    Chain-follower (extends apex-dashboard pattern),
                              SSE event stream + REST queries
  indexer-ui/                 Operator dashboard (events + suppliers + escrow
                              lookup)
  tests/                      1098 vitest unit + integration tests (10 skipped
                              under M1-F-D-defer for UPLC test plumbing)
  deploy/testnet/             Per-service docker-compose for the Hetzner stack
  docs/EXECUTIVE_SUMMARY.*    Marp deck (PDF + PPTX) — 11 slides, mermaid
                              diagrams pre-rendered for portable export
  docs/CLOUDFLARE_WAF_BYPASS.md  CF Skip-rule runbook for /v1/* paths
  docs/ARCHITECTURE.md        canonical spec

Live demo (testnet, 2026-05-06):
  https://mp-buyers.vector.testnet.apexfusion.org      (buyer SPA)
  https://mp-suppliers.vector.testnet.apexfusion.org   (supplier API)
  https://mp-indexer.vector.testnet.apexfusion.org     (indexer + UI)
2026-05-06 13:27:48 +02:00

474 lines
18 KiB
TypeScript

/**
* supplier-job-runner.test.ts — RED phase tests for runChatJob
*
* M1-F-async-chat RED phase — Caroline, 2026-04-28
*
* Tests the background job runner:
* Ollama → receipt → Submit → jobs.complete / jobs.fail → lock release
*
* All external side-effects are mocked. Chain, Ollama, and state are injected.
* No real Ogmios / Ollama calls.
*
* ORDERING CONTRACT (documented, not silently assumed):
* jobs.setRunning MUST be called before callOllama.
* If Catherine implements it in the opposite order, the ordering test will
* fail and must be reported rather than silently corrected.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { SupplierState } from "../../supplier/src/state.js";
import { JobStore } from "../../supplier/src/jobs.js";
import type { JobStore as JobStoreType } from "../../supplier/src/jobs.js";
import { runChatJob } from "../../supplier/src/jobRunner.js";
import type { RunChatJobParams } from "../../supplier/src/jobRunner.js";
import * as ollamaMod from "../../supplier/src/ollama.js";
import { buildSampleConfig } from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey, SUPPLIER_PKH } from "../fixtures/supplier-side/wallet-keys.js";
import {
ESCROW_TX_HASH,
TEST_MODEL,
TEST_MESSAGES,
PROMPT_HASH,
POSTED_AT,
DELIVER_BY,
PAYMENT_LOVELACE,
BUYER_BOND,
SUPPLIER_BOND,
CAPABILITY_ID,
REQUEST_SPEC_HASH,
} from "../fixtures/supplier-side/sample-escrow-state.js";
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
import { encodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
import type { OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const ESCROW_REF_STR = `${ESCROW_TX_HASH}#0`;
const CLAIMED_TX_HASH = "1".repeat(64);
const CLAIMED_REF: OutputReference = { txHash: CLAIMED_TX_HASH, index: 0 };
const SUBMIT_TX_HASH = "2".repeat(64);
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const HEX64_RE = /^[0-9a-f]{64}$/i;
const HEX128_RE = /^[0-9a-f]{128}$/i;
function buildAdvert(): AdvertDatum {
return {
supplier_pkh: SUPPLIER_PKH,
capability_id: CAPABILITY_ID,
model: TEST_MODEL,
max_output_tokens: 512,
max_processing_ms: 60_000,
price_lovelace: 2_000_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 buildEscrowDatum(): EscrowDatum {
return {
buyer_pkh: "1234567890abcdef1234567890abcdef1234567890abcdef12345678",
supplier_pkh: SUPPLIER_PKH,
advert_ref: { txHash: "b".repeat(64), index: 0 },
capability_id: CAPABILITY_ID,
request_spec_hash: REQUEST_SPEC_HASH,
prompt_hash: PROMPT_HASH,
payment_lovelace: PAYMENT_LOVELACE,
buyer_bond_lovelace: BUYER_BOND,
supplier_bond_lovelace: SUPPLIER_BOND,
deliver_by: DELIVER_BY,
posted_at: POSTED_AT,
submitted_at: null,
result_receipt_hash: null,
state: "Claimed",
};
}
function buildOllamaOkResult() {
return {
content: "I am a helpful assistant.",
prompt_tokens: 12,
completion_tokens: 48,
wallclock_ms: 3200,
};
}
function makeParams(
chain: MockChainProvider,
state: SupplierState,
jobs: JobStoreType,
jobId: string,
): RunChatJobParams {
return {
deps: {
chain,
state,
config: buildSampleConfig(),
supplierKey: buildSupplierWalletKey(),
jobs,
},
jobId,
escrowRef: ESCROW_REF_STR,
claimedRef: CLAIMED_REF,
advert: buildAdvert(),
escrowDatum: buildEscrowDatum(),
requestBody: { messages: TEST_MESSAGES },
};
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function mockOllamaOk() {
return vi.spyOn(ollamaMod, "callOllama").mockResolvedValue(buildOllamaOkResult());
}
function mockOllamaFail(message = "Ollama returned HTTP 500") {
return vi.spyOn(ollamaMod, "callOllama").mockRejectedValue(
new ollamaMod.OllamaError("ollama_failure", message),
);
}
// ─── Happy path ───────────────────────────────────────────────────────────────
describe("runChatJob — happy path", () => {
let chain: MockChainProvider;
let state: SupplierState;
let jobs: JobStore;
beforeEach(() => {
chain = new MockChainProvider();
chain.advanceSlot(1_000);
state = new SupplierState();
state.tryAcquire(ESCROW_REF_STR);
jobs = new JobStore();
mockOllamaOk();
// Mock submitTx to return deterministic hashes
vi.spyOn(chain, "submitTx").mockResolvedValue(SUBMIT_TX_HASH);
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
// Seed the claimedRef UTxO so buildSubmitTx's chain.queryUtxo(escrowRef)
// can find it once Catherine flips the runner back to buildSubmitTx.
// state="Claimed", submitted_at=null, result_receipt_hash=null per spec.
chain.seed({
ref: CLAIMED_REF,
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
lovelace: 4_000_000n,
assets: {},
datumHex: encodeEscrowDatum(buildEscrowDatum()),
scriptRef: null,
});
// Mock awaitTx to resolve immediately after successful submitTx.
// Without this mock, MockChainProvider.awaitTx polls for SUBMIT_TX_HASH
// which is never added to knownTxs (submitTx is mocked), causing a 200ms
// hang. Once Catherine flips the runner to buildSubmitTx, the real
// chain.submitTx will add the hash to knownTxs and awaitTx finds it;
// this spy then becomes a no-op override — safe to leave in.
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
});
afterEach(() => { vi.restoreAllMocks(); });
it("calls setRunning before callOllama (ordering contract)", async () => {
// RED — throws "not implemented"
// ORDERING CONTRACT: setRunning must precede callOllama.
// If Catherine inverts this order, the test fails and must be flagged.
const callOrder: string[] = [];
const jobId = jobs.create(ESCROW_REF_STR);
const setRunningSpy = vi.spyOn(jobs, "setRunning").mockImplementation((id) => {
callOrder.push(`setRunning:${id}`);
});
const ollamaSpy = vi.spyOn(ollamaMod, "callOllama").mockImplementation(async () => {
callOrder.push("callOllama");
return buildOllamaOkResult();
});
const completeSpy = vi.spyOn(jobs, "complete").mockImplementation(() => {});
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(callOrder.indexOf(`setRunning:${jobId}`)).toBeLessThan(
callOrder.indexOf("callOllama"),
);
setRunningSpy.mockRestore();
ollamaSpy.mockRestore();
completeSpy.mockRestore();
});
it("calls jobs.complete with a payload containing choices, usage, receipt, receipt_signature", async () => {
// RED — throws "not implemented"
const jobId = jobs.create(ESCROW_REF_STR);
const completeSpy = vi.spyOn(jobs, "complete");
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(completeSpy).toHaveBeenCalledOnce();
const [calledJobId, payload] = completeSpy.mock.calls[0];
expect(calledJobId).toBe(jobId);
expect(Array.isArray(payload.choices)).toBe(true);
expect(payload.choices[0].message.role).toBe("assistant");
expect(typeof payload.choices[0].message.content).toBe("string");
expect(payload.choices[0].message.content.length).toBeGreaterThan(0);
expect(typeof payload.usage.prompt_tokens).toBe("number");
expect(typeof payload.usage.completion_tokens).toBe("number");
expect(typeof payload.usage.total_tokens).toBe("number");
expect(payload.receipt).toBeTruthy();
expect(typeof payload.receipt_signature).toBe("string");
});
it("receipt contains all 8 required fields with correct types and values", async () => {
// RED — throws "not implemented"
const jobId = jobs.create(ESCROW_REF_STR);
const completeSpy = vi.spyOn(jobs, "complete");
await runChatJob(makeParams(chain, state, jobs, jobId));
const receipt = completeSpy.mock.calls[0][1].receipt as Record<string, unknown>;
// 8 fields: prompt_hash, response_hash, model, prompt_tokens,
// completion_tokens, wallclock_ms, supplier_pkh, escrow_ref
expect(typeof receipt.prompt_hash).toBe("string");
expect(HEX64_RE.test(receipt.prompt_hash as string)).toBe(true);
expect(receipt.prompt_hash).toBe(PROMPT_HASH);
expect(typeof receipt.response_hash).toBe("string");
expect(HEX64_RE.test(receipt.response_hash as string)).toBe(true);
expect(receipt.model).toBe(TEST_MODEL);
expect(typeof receipt.prompt_tokens).toBe("number");
expect(receipt.prompt_tokens).toBe(12);
expect(typeof receipt.completion_tokens).toBe("number");
expect(receipt.completion_tokens).toBe(48);
expect(typeof receipt.wallclock_ms).toBe("number");
// wallclock_ms comes from Ollama result, not total job time
expect(receipt.wallclock_ms).toBe(3200);
expect(receipt.supplier_pkh).toBe(SUPPLIER_PKH);
expect(receipt.escrow_ref).toBe(ESCROW_REF_STR);
});
it("receipt_signature is a 128-char hex string (64-byte Ed25519)", async () => {
// RED — throws "not implemented"
const jobId = jobs.create(ESCROW_REF_STR);
const completeSpy = vi.spyOn(jobs, "complete");
await runChatJob(makeParams(chain, state, jobs, jobId));
const sig = completeSpy.mock.calls[0][1].receipt_signature;
expect(HEX128_RE.test(sig)).toBe(true);
});
it("wallclock_ms in receipt equals the wallclock_ms from Ollama (not total job time)", async () => {
// RED — throws "not implemented"
// The receipt's wallclock_ms must be inference.wallclock_ms, not wall clock
// elapsed for the whole runChatJob invocation.
const jobId = jobs.create(ESCROW_REF_STR);
const completeSpy = vi.spyOn(jobs, "complete");
await runChatJob(makeParams(chain, state, jobs, jobId));
const receipt = completeSpy.mock.calls[0][1].receipt as Record<string, unknown>;
// Our mock returns wallclock_ms = 3200; that must be what ends up in receipt
expect(receipt.wallclock_ms).toBe(3200);
});
it("releases supplier lock on success (try/finally)", async () => {
// RED — throws "not implemented"
const jobId = jobs.create(ESCROW_REF_STR);
expect(state.snapshot().status).toBe("working");
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(state.snapshot().status).toBe("free");
});
});
// ─── Ollama failure ───────────────────────────────────────────────────────────
describe("runChatJob — Ollama failure", () => {
let chain: MockChainProvider;
let state: SupplierState;
let jobs: JobStore;
beforeEach(() => {
chain = new MockChainProvider();
chain.advanceSlot(1_000);
state = new SupplierState();
state.tryAcquire(ESCROW_REF_STR);
jobs = new JobStore();
vi.spyOn(chain, "submitTx").mockResolvedValue(SUBMIT_TX_HASH);
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
// Seed claimedRef and mock awaitTx for Catherine's buildSubmitTx flip.
// Ollama failure tests don't reach Submit, so these are defensive fixtures.
chain.seed({
ref: CLAIMED_REF,
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
lovelace: 4_000_000n,
assets: {},
datumHex: encodeEscrowDatum(buildEscrowDatum()),
scriptRef: null,
});
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
});
afterEach(() => { vi.restoreAllMocks(); });
it("calls jobs.fail with httpStatus=502, reason=ollama_failure on Ollama error", async () => {
// RED — throws "not implemented"
mockOllamaFail("Ollama returned HTTP 500: Internal Server Error");
const jobId = jobs.create(ESCROW_REF_STR);
const failSpy = vi.spyOn(jobs, "fail");
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(failSpy).toHaveBeenCalledOnce();
const [calledJobId, failure] = failSpy.mock.calls[0];
expect(calledJobId).toBe(jobId);
expect(failure.httpStatus).toBe(502);
expect(failure.reason).toBe("ollama_failure");
expect(typeof failure.message).toBe("string");
});
it("releases supplier lock after Ollama failure (try/finally)", async () => {
// RED — throws "not implemented"
mockOllamaFail();
const jobId = jobs.create(ESCROW_REF_STR);
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(state.snapshot().status).toBe("free");
});
it("does not throw — resolves after Ollama failure", async () => {
// RED — throws "not implemented"
mockOllamaFail();
const jobId = jobs.create(ESCROW_REF_STR);
await expect(runChatJob(makeParams(chain, state, jobs, jobId))).resolves.toBeUndefined();
});
});
// ─── Submit tx failure ────────────────────────────────────────────────────────
describe("runChatJob — Submit tx failure", () => {
let chain: MockChainProvider;
let state: SupplierState;
let jobs: JobStore;
beforeEach(() => {
chain = new MockChainProvider();
chain.advanceSlot(1_000);
state = new SupplierState();
state.tryAcquire(ESCROW_REF_STR);
jobs = new JobStore();
mockOllamaOk();
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
// Seed claimedRef UTxO. In Submit-failure tests, submitTx is mocked to
// reject so awaitTx is never reached; these fixtures are defensive for
// Catherine's buildSubmitTx flip.
chain.seed({
ref: CLAIMED_REF,
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
lovelace: 4_000_000n,
assets: {},
datumHex: encodeEscrowDatum(buildEscrowDatum()),
scriptRef: null,
});
vi.spyOn(chain, "awaitTx").mockResolvedValue(undefined);
});
afterEach(() => { vi.restoreAllMocks(); });
it("calls jobs.fail with httpStatus=502, reason=submit_failed when Submit tx throws", async () => {
// RED — throws "not implemented"
vi.spyOn(chain, "submitTx").mockRejectedValue(new Error("phase-2 script rejected"));
const jobId = jobs.create(ESCROW_REF_STR);
const failSpy = vi.spyOn(jobs, "fail");
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(failSpy).toHaveBeenCalledOnce();
const [calledJobId, failure] = failSpy.mock.calls[0];
expect(calledJobId).toBe(jobId);
expect(failure.httpStatus).toBe(502);
expect(failure.reason).toBe("submit_failed");
});
it("releases lock after Submit tx failure", async () => {
// RED — throws "not implemented"
vi.spyOn(chain, "submitTx").mockRejectedValue(new Error("phase-2 script rejected"));
const jobId = jobs.create(ESCROW_REF_STR);
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(state.snapshot().status).toBe("free");
});
it("does not throw — resolves after Submit tx failure", async () => {
// RED — throws "not implemented"
vi.spyOn(chain, "submitTx").mockRejectedValue(new Error("rejected"));
const jobId = jobs.create(ESCROW_REF_STR);
await expect(runChatJob(makeParams(chain, state, jobs, jobId))).resolves.toBeUndefined();
});
});
// ─── awaitTx timeout on Submit ────────────────────────────────────────────────
describe("runChatJob — awaitTx timeout on Submit", () => {
let chain: MockChainProvider;
let state: SupplierState;
let jobs: JobStore;
beforeEach(() => {
chain = new MockChainProvider();
chain.advanceSlot(1_000);
state = new SupplierState();
state.tryAcquire(ESCROW_REF_STR);
jobs = new JobStore();
mockOllamaOk();
vi.spyOn(chain, "submitTx").mockResolvedValue(SUBMIT_TX_HASH);
// Fix A — 2026-04-28 M1-F-async-chat-cleanup:
// Seed claimedRef UTxO for buildSubmitTx flip. awaitTx is mocked per-test
// to reject (submit_timeout scenario); do NOT add a resolve-mock here.
chain.seed({
ref: CLAIMED_REF,
address: "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6",
lovelace: 4_000_000n,
assets: {},
datumHex: encodeEscrowDatum(buildEscrowDatum()),
scriptRef: null,
});
// NOTE: awaitTx is intentionally NOT mocked here at the describe level —
// individual tests mock it to reject with a timeout error.
// This is the only describe block that keeps the submit_timeout RED until
// Catherine flips the 60_000ms budget (see Catherine's flip checklist).
});
afterEach(() => { vi.restoreAllMocks(); });
it("calls jobs.fail with reason=submit_timeout when awaitTx rejects", async () => {
// RED — throws "not implemented"
vi.spyOn(chain, "awaitTx").mockRejectedValue(new Error("awaitTx timed out"));
const jobId = jobs.create(ESCROW_REF_STR);
const failSpy = vi.spyOn(jobs, "fail");
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(failSpy).toHaveBeenCalledOnce();
const [calledJobId, failure] = failSpy.mock.calls[0];
expect(calledJobId).toBe(jobId);
expect(failure.httpStatus).toBe(502);
expect(failure.reason).toBe("submit_timeout");
});
it("releases lock after awaitTx timeout", async () => {
// RED — throws "not implemented"
vi.spyOn(chain, "awaitTx").mockRejectedValue(new Error("timeout"));
const jobId = jobs.create(ESCROW_REF_STR);
await runChatJob(makeParams(chain, state, jobs, jobId));
expect(state.snapshot().status).toBe("free");
});
});