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
208 lines
6.8 KiB
TypeScript
208 lines
6.8 KiB
TypeScript
import type { BackgroundJobHandlers } from "@rakazo/adapter-kit";
|
|
import { Pool } from "pg";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { GraphileJobPublisher, GraphileJobWorkerHost } from "./wakeup.js";
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
const describePostgres =
|
|
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
|
|
|
|
let pool: Pool | undefined;
|
|
function testPool(): Pool {
|
|
pool ??= new Pool({ connectionString: databaseUrl, max: 4 });
|
|
return pool;
|
|
}
|
|
afterEach(async () => {
|
|
await pool?.end();
|
|
pool = undefined;
|
|
});
|
|
|
|
function handlers(overrides: Partial<BackgroundJobHandlers> = {}): 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),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function waitFor(assertion: () => void, timeoutMs = 10_000): Promise<void> {
|
|
await vi.waitFor(assertion, { timeout: timeoutMs, interval: 25 });
|
|
}
|
|
|
|
describePostgres("Graphile background jobs (PostgreSQL contract)", () => {
|
|
it("waits for an active handler during graceful shutdown", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
const entered = deferred();
|
|
const release = deferred();
|
|
let completed = false;
|
|
const target = handlers({
|
|
"run.continue": async () => {
|
|
entered.resolve();
|
|
await release.promise;
|
|
completed = true;
|
|
},
|
|
});
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({ name: "run.continue", payload: { runId: "graceful-stop" } });
|
|
await entered.promise;
|
|
|
|
let stopped = false;
|
|
const stopping = host.stop().then(() => {
|
|
stopped = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(stopped).toBe(false);
|
|
|
|
release.resolve();
|
|
await stopping;
|
|
expect(completed).toBe(true);
|
|
} finally {
|
|
release.resolve();
|
|
await host.stop();
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("replaces a keyed delayed job without running the old payload or schedule", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
const received: string[] = [];
|
|
const target = handlers({
|
|
"computer.sleep": async ({ computerId }) => {
|
|
received.push(computerId);
|
|
},
|
|
});
|
|
const key = `contract:replacement:${Date.now()}`;
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "old" },
|
|
availableAt: new Date(Date.now() + 300),
|
|
replaceKey: key,
|
|
});
|
|
await publisher.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "replacement" },
|
|
availableAt: new Date(Date.now() + 800),
|
|
replaceKey: key,
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
expect(received).toEqual([]);
|
|
await waitFor(() => expect(received).toEqual(["replacement"]));
|
|
} finally {
|
|
await host.stop();
|
|
await publisher.cancel(key).catch(() => undefined);
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("removes a keyed delayed job before a worker can run it", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
const target = handlers();
|
|
const key = `contract:cancellation:${Date.now()}`;
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "cancelled" },
|
|
availableAt: new Date(Date.now() + 300),
|
|
replaceKey: key,
|
|
});
|
|
await publisher.cancel(key);
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
expect(target["computer.sleep"]).not.toHaveBeenCalled();
|
|
} finally {
|
|
await host.stop();
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("runs jobs that share a queue one at a time across workers", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const first = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
|
|
const second = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
|
|
const queue = `contract-queue-${Date.now()}`;
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const done: string[] = [];
|
|
const target = handlers({
|
|
"media.run": async ({ jobId }) => {
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
active -= 1;
|
|
done.push(jobId);
|
|
},
|
|
});
|
|
|
|
try {
|
|
await first.start(target);
|
|
await second.start(target);
|
|
for (const jobId of ["q1", "q2", "q3"]) {
|
|
await publisher.enqueue({ name: "media.run", payload: { jobId }, queue });
|
|
}
|
|
await waitFor(() => expect(done).toHaveLength(3));
|
|
expect(maxActive).toBe(1);
|
|
} finally {
|
|
await first.stop();
|
|
await second.stop();
|
|
await publisher.close();
|
|
}
|
|
});
|
|
|
|
it("persists a handler failure and retries the job successfully", async () => {
|
|
const publisher = new GraphileJobPublisher(testPool());
|
|
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
|
|
let attempts = 0;
|
|
const target = handlers({
|
|
"run.continue": async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new Error("intentional contract-test failure");
|
|
},
|
|
});
|
|
const key = `contract:retry:${Date.now()}`;
|
|
|
|
try {
|
|
await host.start(target);
|
|
await publisher.enqueue({
|
|
name: "run.continue",
|
|
payload: { runId: "retry" },
|
|
replaceKey: key,
|
|
});
|
|
await waitFor(() => expect(attempts).toBe(2), 15_000);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(attempts).toBe(2);
|
|
} finally {
|
|
await host.stop();
|
|
await publisher.cancel(key).catch(() => undefined);
|
|
await publisher.close();
|
|
}
|
|
});
|
|
});
|