diff --git a/packages/gittensory-engine/src/discovery-soft-claim.ts b/packages/gittensory-engine/src/discovery-soft-claim.ts new file mode 100644 index 0000000000..32736e3f0d --- /dev/null +++ b/packages/gittensory-engine/src/discovery-soft-claim.ts @@ -0,0 +1,96 @@ +import { DISCOVERY_INDEX_CONTRACT_VERSION } from "./discovery-index-contract.js"; + +// Soft-claim coordination request builder (#4302). The local soft-claim ledger (claim-ledger.js) is 100% +// client-side — "never uploads, syncs, or phones home" — and duplicate-cluster adjudication +// (isDuplicateClusterWinnerByClaim, #3355) only resolves collisions AFTER the fact, by observing which PR/comment +// publicly landed first. This module closes that gap on the client side: a pure function that turns a local claim +// record into the request payload a miner would send to the optional hosted discovery-index (the contract in +// discovery-index-contract.ts, #4300) to softly announce/reserve an issue across the fleet BEFORE starting, so +// collisions are reduced rather than only detected afterward. +// +// Scoped as a "request builder", not a network client: pure input→output, no HTTP (wiring the hosted plane's +// client into the miner runtime is downstream of #4250 existing). It shares the discovery-index contract's posture: +// metadata-only and public-safe by construction — the request is built by explicitly copying a fixed set of +// known fields, never by spreading the input, so no unexpected/forbidden field can ride along. +// +// DECISION (the issue's open question — reject vs. release for non-active claims): a `released`/`expired` claim +// produces an explicit `release` request variant rather than being rejected, so the fleet learns an issue is free +// again; only an `active` claim produces a `claim` request. + +/** The three local claim-ledger statuses (claim-ledger.js `CLAIM_STATUSES`). */ +export type SoftClaimStatus = "active" | "released" | "expired"; + +/** Outbound coordination actions: announce a claim, or announce that a prior claim is released. */ +export type SoftClaimAction = "claim" | "release"; + +/** The local claim-ledger record shape (claim-ledger.js `rowToClaim`) this builder reads from. */ +export type SoftClaimRecord = { + repoFullName: string; + issueNumber: number; + claimedAt: string; + status: SoftClaimStatus; + note?: string | null; +}; + +/** Optional caller context. `instanceId` is an opaque, caller-anonymized fleet handle — NOT a wallet/hotkey or any + * identity secret; it is copied through verbatim and never interpreted here. */ +export type SoftClaimRequestContext = { + instanceId?: string; +}; + +/** The public-safe soft-claim coordination request payload targeting the discovery-index contract. */ +export type SoftClaimRequest = { + contractVersion: number; + action: SoftClaimAction; + repoFullName: string; + issueNumber: number; + claimedAt: string; + note: string | null; + instanceId: string | null; +}; + +/** `active` announces a `claim`; `released`/`expired` announce a `release`. */ +export function softClaimActionForStatus(status: SoftClaimStatus): SoftClaimAction { + return status === "active" ? "claim" : "release"; +} + +/** `owner/repo` with exactly one slash and non-empty halves; anything else → null (mirrors the discovery-index + * contract / claim-ledger repo validation). */ +function normalizeRepoFullName(value: unknown): string | null { + if (typeof value !== "string") return null; + const [owner, repo, extra] = value.trim().split("/"); + if (!owner || !repo || extra !== undefined) return null; + return `${owner}/${repo}`; +} + +function isSoftClaimStatus(value: unknown): value is SoftClaimStatus { + return value === "active" || value === "released" || value === "expired"; +} + +/** + * Build a public-safe soft-claim coordination request from a local claim-ledger record. Pure and network-free. + * Returns null when the claim is missing/invalid or carries an unknown status (only the three claim-ledger + * statuses map to a request). Only the fixed set of known fields is copied onto the request, so the payload stays + * metadata-only by construction. + */ +export function buildSoftClaimRequest(claim: unknown, context: SoftClaimRequestContext = {}): SoftClaimRequest | null { + if (!claim || typeof claim !== "object" || Array.isArray(claim)) return null; + const record = claim as Record; + const repoFullName = normalizeRepoFullName(record.repoFullName); + if (repoFullName === null) return null; + const issueNumber = record.issueNumber; + if (typeof issueNumber !== "number" || !Number.isInteger(issueNumber) || issueNumber <= 0) return null; + if (typeof record.claimedAt !== "string" || record.claimedAt.trim() === "") return null; + if (!isSoftClaimStatus(record.status)) return null; + const note = typeof record.note === "string" && record.note.trim() !== "" ? record.note : null; + const instanceId = typeof context.instanceId === "string" && context.instanceId.trim() !== "" ? context.instanceId : null; + return { + contractVersion: DISCOVERY_INDEX_CONTRACT_VERSION, + action: softClaimActionForStatus(record.status), + repoFullName, + issueNumber, + claimedAt: record.claimedAt, + note, + instanceId, + }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 2fc4f34606..21240d08c9 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -262,6 +262,15 @@ export { type ParsedDiscoveryIndexRequest, type ParsedDiscoveryIndexResponse, } from "./discovery-index-contract.js"; +export { + buildSoftClaimRequest, + softClaimActionForStatus, + type SoftClaimAction, + type SoftClaimRecord, + type SoftClaimRequest, + type SoftClaimRequestContext, + type SoftClaimStatus, +} from "./discovery-soft-claim.js"; export { computeMetadataLaneFit, computeMinerGoalLaneFit, diff --git a/test/unit/discovery-soft-claim.test.ts b/test/unit/discovery-soft-claim.test.ts new file mode 100644 index 0000000000..4e997fc18c --- /dev/null +++ b/test/unit/discovery-soft-claim.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + DISCOVERY_INDEX_CONTRACT_VERSION, + buildSoftClaimRequest, + softClaimActionForStatus, +} from "../../packages/gittensory-engine/src/index"; + +const ACTIVE_CLAIM = { + id: 7, + repoFullName: "owner/repo", + issueNumber: 42, + claimedAt: "2026-01-01T00:00:00Z", + status: "active" as const, + note: "picked from the miner lane", +}; + +describe("soft-claim coordination request builder (#4302)", () => { + it("re-exports the builder API from the engine barrel", () => { + expect(typeof buildSoftClaimRequest).toBe("function"); + expect(typeof softClaimActionForStatus).toBe("function"); + }); + + it("builds a public-safe claim request from an active claim, copying only known fields", () => { + const req = buildSoftClaimRequest(ACTIVE_CLAIM, { instanceId: "inst-abc" }); + expect(req).toEqual({ + contractVersion: DISCOVERY_INDEX_CONTRACT_VERSION, + action: "claim", + repoFullName: "owner/repo", + issueNumber: 42, + claimedAt: "2026-01-01T00:00:00Z", + note: "picked from the miner lane", + instanceId: "inst-abc", + }); + // the local ledger `id` is not leaked into the outbound request + expect(Object.keys(req ?? {})).not.toContain("id"); + }); + + it("maps released/expired claims to a release action", () => { + expect(softClaimActionForStatus("active")).toBe("claim"); + expect(softClaimActionForStatus("released")).toBe("release"); + expect(softClaimActionForStatus("expired")).toBe("release"); + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, status: "released" })?.action).toBe("release"); + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, status: "expired" })?.action).toBe("release"); + }); + + it("normalizes note and instanceId to null when blank, non-string, or absent", () => { + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, note: " " })?.note).toBeNull(); + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, note: 5 })?.note).toBeNull(); + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, note: undefined })?.note).toBeNull(); + expect(buildSoftClaimRequest(ACTIVE_CLAIM)?.instanceId).toBeNull(); + expect(buildSoftClaimRequest(ACTIVE_CLAIM, { instanceId: " " })?.instanceId).toBeNull(); + expect(buildSoftClaimRequest(ACTIVE_CLAIM, { instanceId: 9 as unknown as string })?.instanceId).toBeNull(); + }); + + it("returns null for a non-object claim", () => { + for (const bad of [null, undefined, 42, "claim", [ACTIVE_CLAIM]]) { + expect(buildSoftClaimRequest(bad)).toBeNull(); + } + }); + + it("returns null for an invalid repoFullName", () => { + for (const repoFullName of [123, "no-slash", "owner/", "/repo", "a/b/c"]) { + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, repoFullName })).toBeNull(); + } + }); + + it("returns null for an invalid issueNumber", () => { + for (const issueNumber of [0, -1, 1.5, "42", undefined]) { + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, issueNumber })).toBeNull(); + } + }); + + it("returns null for a missing/blank/non-string claimedAt", () => { + for (const claimedAt of [undefined, "", " ", 123]) { + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, claimedAt })).toBeNull(); + } + }); + + it("returns null for an unknown claim status", () => { + for (const status of ["done", "", undefined, "ACTIVE"]) { + expect(buildSoftClaimRequest({ ...ACTIVE_CLAIM, status })).toBeNull(); + } + }); +});