Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions packages/gittensory-engine/src/discovery-soft-claim.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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,
};
}
9 changes: 9 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions test/unit/discovery-soft-claim.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});