diff --git a/desktop/src/features/providers/lib/providerAvailability.test.mjs b/desktop/src/features/providers/lib/providerAvailability.test.mjs new file mode 100644 index 00000000000..c862a775cd5 --- /dev/null +++ b/desktop/src/features/providers/lib/providerAvailability.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canQuoteJobs, + deriveProviderAvailability, + providerAvailabilityCaption, + refreshIntervalForLease, +} from "./providerAvailability.ts"; + +/** + * Covers buzz#84's freshness invariant (toon-meta#262 decision 12): never + * advertise a provider as available for longer than it stays routable on the + * connector, and a dropped session must cost reachability only — never the + * channel, balance, nonce position or reputation. Pure-function tests in the + * `huddleFeeQuote` mold — no DOM, no live connector. + */ + +test("the refresh cadence mirrors the mesh's 45s/120s reference ratio", () => { + assert.equal(refreshIntervalForLease(120_000), 45_000); + assert.equal(refreshIntervalForLease(60_000), 22_500); +}); + +test("advertising off reads as unadvertised regardless of session state", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: false, + sessionConnected: true, + lastAdvertisedAtMs: 1_000, + nowMs: 1_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "unadvertised" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("advertising on with no publish yet is pending, not available", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: null, + nowMs: 1_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "pending" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("a live, fresh advertisement is available and reports its next refresh and expiry", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: 100_000, + nowMs: 110_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { + kind: "available", + refreshDueAtMs: 145_000, + expiresAtMs: 220_000, + }); + assert.equal(canQuoteJobs(state), true); +}); + +test("a dropped session reads as stale immediately, mid-lease — offline costs reachability only", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: false, + lastAdvertisedAtMs: 100_000, + nowMs: 100_500, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "stale" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("a missed refresh past the lease reads as stale — never advertise longer than routable", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: 0, + nowMs: 120_000, + sessionLeaseTtlMs: 120_000, + }); + + assert.deepEqual(state, { kind: "stale" }); + assert.equal(canQuoteJobs(state), false); +}); + +test("a lease boundary reads as fresh right up to, but not including, expiry", () => { + const state = deriveProviderAvailability({ + advertisingEnabled: true, + sessionConnected: true, + lastAdvertisedAtMs: 0, + nowMs: 119_999, + sessionLeaseTtlMs: 120_000, + }); + + assert.equal(state.kind, "available"); +}); + +test("caption text is honest and non-null only where there is something to say", () => { + assert.equal(providerAvailabilityCaption({ kind: "unadvertised" }), null); + assert.match(providerAvailabilityCaption({ kind: "pending" }), /Publishing/); + assert.match( + providerAvailabilityCaption({ + kind: "available", + refreshDueAtMs: 0, + expiresAtMs: 0, + }), + /available/i, + ); + assert.match( + providerAvailabilityCaption({ kind: "stale" }), + /not currently reachable/i, + ); +}); diff --git a/desktop/src/features/providers/lib/providerAvailability.ts b/desktop/src/features/providers/lib/providerAvailability.ts new file mode 100644 index 00000000000..c0926df6b71 --- /dev/null +++ b/desktop/src/features/providers/lib/providerAvailability.ts @@ -0,0 +1,122 @@ +/** + * The provider-surface freshness invariant (buzz#84, toon-meta#262 decision + * 12): "the socket is the lease." A route exists exactly as long as the + * authenticated connector session, so an advertisement must never claim + * availability for longer than that session stays routable — or a buyer can + * pay for a job that cannot land. + * + * Two failure directions are asymmetric and the code below only ever errs + * toward the cheap one: reading a live provider as unavailable costs a + * little missed work; reading an unreachable one as available costs a buyer + * a rejected payment. A dropped session therefore reads as `stale` + * immediately, without waiting out the lease — and going offline costs + * reachability only, never the channel, balance, nonce position or + * reputation (nothing here touches any of those). + * + * `sessionLeaseTtlMs` is a caller-supplied parameter, not a constant defined + * here: connector#698 exports it as `SESSION_LEASE_BACKSTOP_TTL` and its own + * doc comment says this ticket should read it from there rather than + * duplicating the value. As of this writing that export is not yet present + * in any published `@toon-protocol/client`/`@toon-protocol/connector` + * release this repo can pin, so there is no live caller yet — this module is + * ready for one once the value is available, mirroring the + * `agentNetworkFlow.ts` precedent for shipping pure domain logic ahead of + * its data source. + */ + +/** What the provider surface knows about its own current advertisement. */ +export type ProviderAvailability = + /** The owner has not turned provider advertising on for this agent. */ + | { kind: "unadvertised" } + /** Advertising is on, but no publish has landed yet — nothing to quote from. */ + | { kind: "pending" } + /** Advertised, session live, and inside the connector's lease window. */ + | { kind: "available"; refreshDueAtMs: number; expiresAtMs: number } + /** Session dropped, or the lease lapsed without a refresh — do not quote. */ + | { kind: "stale" }; + +/** + * The mesh's own reference cadence: `mesh_llm/coordinator.rs` + * `STATUS_PUBLISH_INTERVAL` republishes every 45s within the 120s freshness + * window `mesh_llm/discovery.rs` `STATUS_FRESHNESS_SECS` allows. + */ +const MESH_REFRESH_INTERVAL_SECS = 45; +const MESH_FRESHNESS_WINDOW_SECS = 120; + +/** + * How long after publishing an advertisement to refresh it, given the + * connector's session lease. Scaled at the mesh's own ratio so a refresh + * always lands well inside the lease rather than racing its edge. + */ +export function refreshIntervalForLease(sessionLeaseTtlMs: number): number { + return Math.floor( + (sessionLeaseTtlMs * MESH_REFRESH_INTERVAL_SECS) / + MESH_FRESHNESS_WINDOW_SECS, + ); +} + +/** + * Derive whether this agent's provider listing is currently honest to show + * as available, never throwing and never guessing in the buyer's favor. + */ +export function deriveProviderAvailability(input: { + /** Whether the owner has turned provider advertising on for this agent. */ + advertisingEnabled: boolean; + /** Whether the connector session (the lease) is currently up. */ + sessionConnected: boolean; + /** When the advertisement was last published, or null if never. */ + lastAdvertisedAtMs: number | null; + nowMs: number; + /** The connector's session lease TTL — see the module doc for its source. */ + sessionLeaseTtlMs: number; +}): ProviderAvailability { + const { + advertisingEnabled, + sessionConnected, + lastAdvertisedAtMs, + nowMs, + sessionLeaseTtlMs, + } = input; + + if (!advertisingEnabled) return { kind: "unadvertised" }; + if (lastAdvertisedAtMs === null) return { kind: "pending" }; + // Offline costs reachability only, and it costs it immediately — waiting + // out the lease on a dropped session would advertise a provider that + // cannot actually take the job. + if (!sessionConnected) return { kind: "stale" }; + + const expiresAtMs = lastAdvertisedAtMs + sessionLeaseTtlMs; + if (nowMs >= expiresAtMs) return { kind: "stale" }; + + return { + kind: "available", + refreshDueAtMs: + lastAdvertisedAtMs + refreshIntervalForLease(sessionLeaseTtlMs), + expiresAtMs, + }; +} + +/** + * Whether this agent may currently produce a paid quote (buzz#84 gotcha: + * quoting costs money, so nothing should auto-quote outside a state that is + * both advertised and honestly reachable right now). + */ +export function canQuoteJobs(availability: ProviderAvailability): boolean { + return availability.kind === "available"; +} + +/** The caption a provider settings surface shows for each state. */ +export function providerAvailabilityCaption( + availability: ProviderAvailability, +): string | null { + switch (availability.kind) { + case "unadvertised": + return null; + case "pending": + return "Publishing this agent's provider listing…"; + case "available": + return "Advertised as available to buyers on the open job market."; + case "stale": + return "Not currently reachable — this agent's provider listing will not accept new jobs until its connector session reconnects."; + } +}