Files
boss/src/lib/boss-work-claims.ts

259 lines
6.0 KiB
TypeScript

export type BossWorkActorKind = "user" | "agent" | "device" | "thread";
export type BossWorkClaimStatus = "active" | "stealable" | "released";
export type BossWorkClaim = {
claimId: string;
resourceId: string;
actorId: string;
actorKind: BossWorkActorKind;
acquiredAt: string;
expiresAt: string;
status: BossWorkClaimStatus;
stealableAfter?: string;
handoffFromActorId?: string;
reason?: string;
};
export type BossWorkClaimEventType =
| "claim_acquired"
| "claim_conflict"
| "claim_released"
| "claim_handoff_released"
| "claim_handoff_acquired"
| "claim_marked_stealable"
| "claim_stale_detected";
export type BossWorkClaimEvent = {
type: BossWorkClaimEventType;
resourceId: string;
claimId: string;
actorId: string;
at: string;
nextActorId?: string;
reason?: string;
};
export type BossWorkClaimResult = {
ok: boolean;
reason?: "claim_conflict" | "actor_mismatch";
claim?: BossWorkClaim;
conflictingClaim?: BossWorkClaim;
events: BossWorkClaimEvent[];
};
export function claimWork(input: {
claims: BossWorkClaim[];
resourceId: string;
actorId: string;
actorKind: BossWorkActorKind;
now: string;
ttlMs: number;
claimId?: string;
}): BossWorkClaimResult {
const conflictingClaim = input.claims.find(
(claim) =>
claim.resourceId === input.resourceId &&
claim.status === "active" &&
new Date(claim.expiresAt).getTime() > new Date(input.now).getTime() &&
claim.actorId !== input.actorId,
);
if (conflictingClaim) {
return {
ok: false,
reason: "claim_conflict",
conflictingClaim,
events: [
{
type: "claim_conflict",
resourceId: input.resourceId,
claimId: conflictingClaim.claimId,
actorId: input.actorId,
at: input.now,
reason: "claim_conflict",
},
],
};
}
const claim: BossWorkClaim = {
claimId: input.claimId ?? makeClaimId(input.resourceId, input.actorId, input.now),
resourceId: input.resourceId,
actorId: input.actorId,
actorKind: input.actorKind,
acquiredAt: input.now,
expiresAt: new Date(new Date(input.now).getTime() + input.ttlMs).toISOString(),
status: "active",
};
return {
ok: true,
claim,
events: [
{
type: "claim_acquired",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: claim.actorId,
at: input.now,
},
],
};
}
export function releaseClaim(input: {
claim: BossWorkClaim;
actorId: string;
now: string;
reason?: string;
}): BossWorkClaimResult {
if (input.claim.actorId !== input.actorId) {
return {
ok: false,
reason: "actor_mismatch",
claim: input.claim,
events: [],
};
}
const claim = { ...input.claim, status: "released" as const, reason: input.reason };
return {
ok: true,
claim,
events: [
{
type: "claim_released",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: input.actorId,
at: input.now,
reason: input.reason,
},
],
};
}
export function handoffWork(input: {
claim: BossWorkClaim;
fromActorId: string;
toActorId: string;
toActorKind: BossWorkActorKind;
now: string;
ttlMs: number;
reason?: string;
}): BossWorkClaimResult {
if (input.claim.actorId !== input.fromActorId) {
return {
ok: false,
reason: "actor_mismatch",
claim: input.claim,
events: [],
};
}
const claim: BossWorkClaim = {
...input.claim,
claimId: makeClaimId(input.claim.resourceId, input.toActorId, input.now),
actorId: input.toActorId,
actorKind: input.toActorKind,
acquiredAt: input.now,
expiresAt: new Date(new Date(input.now).getTime() + input.ttlMs).toISOString(),
status: "active",
handoffFromActorId: input.fromActorId,
reason: input.reason,
};
return {
ok: true,
claim,
events: [
{
type: "claim_handoff_released",
resourceId: input.claim.resourceId,
claimId: input.claim.claimId,
actorId: input.fromActorId,
at: input.now,
nextActorId: input.toActorId,
reason: input.reason,
},
{
type: "claim_handoff_acquired",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: input.toActorId,
at: input.now,
reason: input.reason,
},
],
};
}
export function markClaimStealable(input: {
claim: BossWorkClaim;
actorId: string;
now: string;
reason?: string;
}): BossWorkClaimResult {
if (input.claim.actorId !== input.actorId) {
return {
ok: false,
reason: "actor_mismatch",
claim: input.claim,
events: [],
};
}
const claim = {
...input.claim,
status: "stealable" as const,
stealableAfter: input.now,
reason: input.reason,
};
return {
ok: true,
claim,
events: [
{
type: "claim_marked_stealable",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: input.actorId,
at: input.now,
reason: input.reason,
},
],
};
}
export function detectStaleClaims(input: { claims: BossWorkClaim[]; now: string }): {
staleClaims: BossWorkClaim[];
events: BossWorkClaimEvent[];
} {
const nowMs = new Date(input.now).getTime();
const staleClaims = input.claims.filter(
(claim) => claim.status !== "released" && new Date(claim.expiresAt).getTime() <= nowMs,
);
return {
staleClaims,
events: staleClaims.map((claim) => ({
type: "claim_stale_detected",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: claim.actorId,
at: input.now,
reason: "claim_expired",
})),
};
}
function makeClaimId(resourceId: string, actorId: string, now: string): string {
return `claim_${slug(resourceId)}_${slug(actorId)}_${new Date(now).getTime()}`;
}
function slug(value: string): string {
return value.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
}