mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
175 lines
5.5 KiB
TypeScript
175 lines
5.5 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createCancelStaleQueuedRun,
|
|
createDispatchResolvedInteractionIfCurrent,
|
|
createEvaluateScheduledRetryGate,
|
|
createPromoteDueScheduledRetries,
|
|
createPromoteScheduledRetry,
|
|
} from "./use-cases.js";
|
|
import type { DueRetryRun, RunDispatchWriter, ScheduledRetryReader } from "./ports.js";
|
|
|
|
function fakeReader(dueRuns: DueRetryRun[] = []): ScheduledRetryReader & { evaluateCalls: unknown[] } {
|
|
const evaluateCalls: unknown[] = [];
|
|
return {
|
|
evaluateCalls,
|
|
async nextScheduledRetryAt() { return null; },
|
|
async evaluateScheduledRetryGate(input) {
|
|
evaluateCalls.push(input);
|
|
return { allowed: true };
|
|
},
|
|
async listDueRetries() {
|
|
return dueRuns;
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeWriter(overrides: Partial<RunDispatchWriter> = {}): RunDispatchWriter & {
|
|
promoteCalls: unknown[];
|
|
cancelCalls: unknown[];
|
|
dispatchCalls: unknown[];
|
|
} {
|
|
const promoteCalls: unknown[] = [];
|
|
const cancelCalls: unknown[] = [];
|
|
const dispatchCalls: unknown[] = [];
|
|
return {
|
|
promoteCalls,
|
|
cancelCalls,
|
|
dispatchCalls,
|
|
async promoteOrCancelDueRetry(input) {
|
|
promoteCalls.push(input);
|
|
return { outcome: "promoted", postCommitEffects: [] };
|
|
},
|
|
async cancelStaleQueuedRun(input) {
|
|
cancelCalls.push(input);
|
|
return { outcome: "not_stale" };
|
|
},
|
|
async dispatchResolvedInteractionIfCurrent(input) {
|
|
dispatchCalls.push(input);
|
|
return { dispatched: true, resultPromise: input.dispatch(() => {}) };
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("createEvaluateScheduledRetryGate", () => {
|
|
it("maps identifiers and a default clock onto the semantic reader operation", async () => {
|
|
const reader = fakeReader();
|
|
const before = Date.now();
|
|
const result = await createEvaluateScheduledRetryGate({ reader })({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
retryReasonOverride: "max_turns_continuation",
|
|
});
|
|
|
|
expect(result).toEqual({ allowed: true });
|
|
expect(reader.evaluateCalls).toHaveLength(1);
|
|
const call = reader.evaluateCalls[0] as { now: Date };
|
|
expect(call).toMatchObject({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
retryReasonOverride: "max_turns_continuation",
|
|
});
|
|
expect(call.now.getTime()).toBeGreaterThanOrEqual(before);
|
|
});
|
|
});
|
|
|
|
describe("createPromoteScheduledRetry", () => {
|
|
it("passes only semantic identifiers and the clock to the atomic operation", async () => {
|
|
const writer = fakeWriter();
|
|
const now = new Date("2026-01-01T00:00:00.000Z");
|
|
const result = await createPromoteScheduledRetry({ writer })({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
now,
|
|
});
|
|
|
|
expect(result).toEqual({ outcome: "promoted", postCommitEffects: [] });
|
|
expect(writer.promoteCalls).toEqual([{ runId: "run-1", companyId: "company-1", now }]);
|
|
});
|
|
|
|
it("passes a gate-suppressed outcome through without a persistence row", async () => {
|
|
const writer = fakeWriter({
|
|
promoteOrCancelDueRetry: vi.fn(async () => ({
|
|
outcome: "gate_suppressed" as const,
|
|
reason: "agent paused",
|
|
errorCode: "agent_not_invokable" as const,
|
|
})),
|
|
});
|
|
const result = await createPromoteScheduledRetry({ writer })({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
});
|
|
expect(result).toEqual({
|
|
outcome: "gate_suppressed",
|
|
reason: "agent paused",
|
|
errorCode: "agent_not_invokable",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createPromoteDueScheduledRetries", () => {
|
|
it("keeps due order and caps a sweep at 50 runs", async () => {
|
|
const dueRuns = Array.from({ length: 75 }, (_, i) => ({
|
|
runId: `run-${i}`,
|
|
companyId: "company-1",
|
|
}));
|
|
const reader = fakeReader(dueRuns);
|
|
const writer = fakeWriter();
|
|
const result = await createPromoteDueScheduledRetries({
|
|
reader,
|
|
promoteScheduledRetry: createPromoteScheduledRetry({ writer }),
|
|
})({ cutoff: null });
|
|
|
|
expect(result.promoted).toBe(50);
|
|
expect(result.runIds).toEqual(dueRuns.slice(0, 50).map(({ runId }) => runId));
|
|
});
|
|
});
|
|
|
|
describe("createCancelStaleQueuedRun", () => {
|
|
it("delegates the complete read-decide-cancel operation to the writer", async () => {
|
|
const cancelStaleQueuedRun = vi.fn(async () => ({
|
|
outcome: "cancelled" as const,
|
|
reason: "issue reassigned",
|
|
errorCode: "issue_assignee_changed" as const,
|
|
postCommitEffects: [],
|
|
}));
|
|
const writer = fakeWriter({
|
|
cancelStaleQueuedRun,
|
|
});
|
|
const now = new Date("2026-01-01T00:00:00.000Z");
|
|
const result = await createCancelStaleQueuedRun({ writer })({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
expectedStatus: "queued",
|
|
now,
|
|
});
|
|
|
|
expect(result.outcome).toBe("cancelled");
|
|
expect(cancelStaleQueuedRun).toHaveBeenCalledWith({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
expectedStatus: "queued",
|
|
now,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createDispatchResolvedInteractionIfCurrent", () => {
|
|
it("delegates the lock, validation, cancellation, and dispatch boundary", async () => {
|
|
const writer = fakeWriter();
|
|
const dispatch = vi.fn(async () => "started");
|
|
const result = await createDispatchResolvedInteractionIfCurrent({ writer })({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
expectedStatus: "running",
|
|
reservationId: "reservation-1",
|
|
enforceContinuationOwnership: false,
|
|
dispatch,
|
|
});
|
|
|
|
expect(result.dispatched).toBe(true);
|
|
expect(writer.dispatchCalls).toHaveLength(1);
|
|
expect(writer.dispatchCalls[0]).toMatchObject({ enforceContinuationOwnership: false });
|
|
});
|
|
});
|