The deployment owner adds folders of photos and videos in the media settings. A per-folder background job indexes them (relative path, kind, size, capture date, dimensions, duration), re-indexes every six hours, and drops files that are gone. The owner's own runs get two tools: search_media (words in folder and file names, kind, capture date range, newest first) and import_media (copies one file into the conversation as an artifact). Files are never changed; hidden entries and symlinks are skipped, and an import must resolve inside its folder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import type { BackgroundJobHandlers } from "@rakazo/adapter-kit";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { InMemoryJobQueue } from "./wakeup.js";
|
|
|
|
function handlers(): BackgroundJobHandlers {
|
|
return {
|
|
"run.continue": vi.fn(async () => undefined),
|
|
"routine.wakeup": vi.fn(async () => undefined),
|
|
"computer.update": vi.fn(async () => undefined),
|
|
"computer.sleep": vi.fn(async () => undefined),
|
|
"computer.control-expire": vi.fn(async () => undefined),
|
|
"skill.teaching-expire": vi.fn(async () => undefined),
|
|
"history.compact": vi.fn(async () => undefined),
|
|
"messaging.deliver": vi.fn(async () => undefined),
|
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
|
"profile.consolidate": vi.fn(async () => undefined),
|
|
"media.run": vi.fn(async () => undefined),
|
|
"media.library.sync": vi.fn(async () => undefined),
|
|
};
|
|
}
|
|
|
|
describe("InMemoryJobQueue", () => {
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("delivers delayed jobs", async () => {
|
|
vi.useFakeTimers();
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await queue.enqueue({
|
|
name: "run.continue",
|
|
payload: { runId: "run-1" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(target["run.continue"]).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(target["run.continue"]).toHaveBeenCalledWith({ runId: "run-1" });
|
|
await queue.close();
|
|
});
|
|
|
|
it("replaces and cancels keyed jobs", async () => {
|
|
vi.useFakeTimers();
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "old" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
replaceKey: "computer.sleep:1",
|
|
});
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "new" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
replaceKey: "computer.sleep:1",
|
|
});
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledTimes(1);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledWith({ computerId: "new" });
|
|
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "cancelled" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
replaceKey: "computer.sleep:2",
|
|
});
|
|
await queue.cancel("computer.sleep:2");
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledTimes(1);
|
|
await queue.close();
|
|
});
|
|
|
|
it.each([
|
|
["stop", "stop"],
|
|
["close", "close"],
|
|
["stop", "close"],
|
|
["close", "stop"],
|
|
] as const)("waits for an active handler before %s/%s", async (first, second) => {
|
|
const queue = new InMemoryJobQueue();
|
|
let releaseHandler: () => void = () => undefined;
|
|
let markStarted: () => void = () => undefined;
|
|
let nestedEnqueueFailed = false;
|
|
let delayedEnqueueFailed = false;
|
|
let chainedEnqueueFailed = false;
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const target = handlers();
|
|
target["computer.sleep"] = vi.fn(async () => {
|
|
try {
|
|
await queue.enqueue({ name: "run.continue", payload: { runId: "chained-run" } });
|
|
} catch {
|
|
chainedEnqueueFailed = true;
|
|
}
|
|
});
|
|
target["run.continue"] = vi.fn(async () => {
|
|
markStarted();
|
|
await new Promise<void>((resolve) => {
|
|
releaseHandler = resolve;
|
|
});
|
|
try {
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "computer-1" },
|
|
replaceKey: "keep-during-close",
|
|
});
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "cancelled-computer" },
|
|
replaceKey: "cancel-during-close",
|
|
});
|
|
await queue.cancel("cancel-during-close");
|
|
} catch {
|
|
nestedEnqueueFailed = true;
|
|
}
|
|
try {
|
|
await queue.enqueue({
|
|
name: "routine.wakeup",
|
|
payload: { routineId: "routine-1", scheduledFor: "2099-01-01T00:00:00.000Z" },
|
|
availableAt: new Date("2099-01-01T00:00:00.000Z"),
|
|
});
|
|
} catch {
|
|
delayedEnqueueFailed = true;
|
|
}
|
|
});
|
|
await queue.start(target);
|
|
await queue.enqueue({ name: "run.continue", payload: { runId: "run-1" } });
|
|
await started;
|
|
|
|
const closing = Promise.all([queue[first](), queue[second]()] as const);
|
|
const closeState = await Promise.race([
|
|
closing.then(() => "closed" as const),
|
|
new Promise<"waiting">((resolve) => setTimeout(() => resolve("waiting"), 5)),
|
|
]);
|
|
releaseHandler();
|
|
await closing;
|
|
expect(closeState).toBe("waiting");
|
|
expect(nestedEnqueueFailed).toBe(false);
|
|
expect(delayedEnqueueFailed).toBe(false);
|
|
expect(chainedEnqueueFailed).toBe(true);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledTimes(1);
|
|
expect(target["run.continue"]).toHaveBeenCalledTimes(1);
|
|
expect(target["routine.wakeup"]).not.toHaveBeenCalled();
|
|
await queue.close();
|
|
});
|
|
|
|
it("rejects a keyed enqueue that resumes after close", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
let releaseCancel: () => void = () => undefined;
|
|
let markCancelling: () => void = () => undefined;
|
|
const cancelling = new Promise<void>((resolve) => {
|
|
markCancelling = resolve;
|
|
});
|
|
vi.spyOn(queue, "cancel").mockImplementationOnce(async () => {
|
|
markCancelling();
|
|
await new Promise<void>((resolve) => {
|
|
releaseCancel = resolve;
|
|
});
|
|
});
|
|
|
|
const enqueue = queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "computer-1" },
|
|
replaceKey: "computer:computer-1",
|
|
});
|
|
await cancelling;
|
|
await queue.close();
|
|
releaseCancel();
|
|
|
|
await expect(enqueue).rejects.toThrow("Background job publisher is closed");
|
|
});
|
|
|
|
it("rejects a keyed enqueue that resumes after stop", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
await queue.start(handlers());
|
|
let releaseCancel: () => void = () => undefined;
|
|
let markCancelling: () => void = () => undefined;
|
|
const cancelling = new Promise<void>((resolve) => {
|
|
markCancelling = resolve;
|
|
});
|
|
vi.spyOn(queue, "cancel").mockImplementationOnce(async () => {
|
|
markCancelling();
|
|
await new Promise<void>((resolve) => {
|
|
releaseCancel = resolve;
|
|
});
|
|
});
|
|
|
|
const enqueue = queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "computer-1" },
|
|
replaceKey: "computer:computer-1",
|
|
});
|
|
await cancelling;
|
|
await queue.stop();
|
|
releaseCancel();
|
|
|
|
await expect(enqueue).rejects.toThrow("Background job publisher is stopped");
|
|
});
|
|
|
|
it("runs jobs of one named queue one at a time, in order", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const order: string[] = [];
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const target = handlers();
|
|
target["media.run"] = async ({ jobId }) => {
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
order.push(`start:${jobId}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
order.push(`end:${jobId}`);
|
|
active -= 1;
|
|
};
|
|
target["computer.sleep"] = async () => {
|
|
order.push("unqueued");
|
|
};
|
|
await queue.start(target);
|
|
|
|
for (const jobId of ["a", "b", "c"]) {
|
|
await queue.enqueue({ name: "media.run", payload: { jobId }, queue: "media" });
|
|
}
|
|
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
|
await queue.close();
|
|
|
|
expect(maxActive).toBe(1);
|
|
expect(order.filter((entry) => entry !== "unqueued")).toEqual([
|
|
"start:a",
|
|
"end:a",
|
|
"start:b",
|
|
"end:b",
|
|
"start:c",
|
|
"end:c",
|
|
]);
|
|
// Jobs outside the queue do not wait for it.
|
|
expect(order.indexOf("unqueued")).toBeLessThan(order.indexOf("end:a"));
|
|
});
|
|
|
|
it("keeps a named queue moving after a job fails", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const ran: string[] = [];
|
|
const target = handlers();
|
|
target["media.run"] = async ({ jobId }) => {
|
|
ran.push(jobId);
|
|
if (jobId === "a") throw new Error("boom");
|
|
};
|
|
await queue.start(target);
|
|
await queue.enqueue({ name: "media.run", payload: { jobId: "a" }, queue: "media" });
|
|
await queue.enqueue({ name: "media.run", payload: { jobId: "b" }, queue: "media" });
|
|
await queue.close();
|
|
expect(ran).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("drains an accepted immediate timer during close", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
|
|
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
|
await queue.close();
|
|
|
|
expect(target["computer.sleep"]).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("rejects jobs after stop until the worker restarts", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await queue.stop();
|
|
|
|
await expect(
|
|
queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } }),
|
|
).rejects.toThrow("Background job publisher is stopped");
|
|
|
|
await queue.start(target);
|
|
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
|
await queue.close();
|
|
expect(target["computer.sleep"]).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("unwraps correlation envelopes and restores request traces", async () => {
|
|
const { createLogger, createTestSink, installLogger, runWithLogContext } = await import(
|
|
"@rakazo/logging"
|
|
);
|
|
const sink = createTestSink();
|
|
installLogger(createLogger({ service: "rakazo-worker", sinks: [sink] }));
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await runWithLogContext(
|
|
{ "trace.id": "a".repeat(32), "span.id": "b".repeat(16), "request.id": "req-1" },
|
|
async () => {
|
|
await queue.enqueue({ name: "run.continue", payload: { runId: "run-traced" } });
|
|
},
|
|
);
|
|
await queue.close();
|
|
expect(target["run.continue"]).toHaveBeenCalledWith({ runId: "run-traced" });
|
|
expect(sink.events.some((event) => event.message === "job.completed")).toBe(true);
|
|
expect(
|
|
sink.events.some(
|
|
(event) => event["trace.id"] === "a".repeat(32) && event["run.id"] === "run-traced",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps wrapped payloads readable by workers that do not unwrap envelopes", async () => {
|
|
const { wrapJobPayload } = await import("@rakazo/logging");
|
|
const { parseBackgroundJob } = await import("@rakazo/adapter-kit");
|
|
const wrapped = wrapJobPayload(
|
|
{ runId: "run-rollback" },
|
|
{ jobId: "job-1", traceId: "a".repeat(32) },
|
|
);
|
|
expect(parseBackgroundJob("run.continue", wrapped)).toEqual({
|
|
name: "run.continue",
|
|
payload: { runId: "run-rollback" },
|
|
});
|
|
});
|
|
});
|