259 lines
6.0 KiB
TypeScript
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();
|
|
}
|