| 1 | // Sprite Task hold for one turn: 5 min expiry refreshed every 60 s, released |
| 2 | // at turn end, capped so a crashed holder leaves only a short tail (S0 Q7). |
| 3 | import { test, after } from "node:test"; |
| 4 | import assert from "node:assert/strict"; |
| 5 | import { spawn } from "node:child_process"; |
| 6 | import fs from "node:fs"; |
| 7 | import http from "node:http"; |
| 8 | import os from "node:os"; |
| 9 | import path from "node:path"; |
| 10 | import { once } from "node:events"; |
| 11 | import { createTaskHold, expireSeconds, spriteApi } from "../src/sprite-task.mjs"; |
| 12 | // These transports are Unix sockets inside the Linux Sprite; Windows cannot bind the path. |
| 13 | const UNIX_SOCKETS = { skip: process.platform === "win32" && "Unix-socket transport (Sprite/Linux only)" }; |
| 14 | |
| 15 | const ROOT = path.resolve(import.meta.dirname, ".."); |
| 16 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-task-")); |
| 17 | after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 18 | |
| 19 | /** A fake /.sprite/api.sock: POST/PUT register, DELETE removes, GET lists. */ |
| 20 | function fakeApi(sock, { putStatus = null } = {}) { |
| 21 | const tasks = new Map(); |
| 22 | const log = []; |
| 23 | const server = http.createServer((req, res) => { |
| 24 | let body = ""; |
| 25 | req.on("data", (c) => { body += c; }); |
| 26 | req.on("end", () => { |
| 27 | log.push(`${req.method} ${req.url} host=${req.headers.host}`); |
| 28 | const send = (status, obj) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(obj ? JSON.stringify(obj) : ""); }; |
| 29 | const name = decodeURIComponent(req.url.split("/")[3] ?? ""); |
| 30 | if (req.method === "GET") return send(200, { tasks: [...tasks.values()] }); |
| 31 | if (req.method === "POST" || req.method === "PUT") { |
| 32 | if (req.method === "PUT" && putStatus) return send(putStatus, null); |
| 33 | const p = JSON.parse(body); |
| 34 | const task = { name: p.name, expire: p.expire, expires_at: "2026-09-22T20:05:00Z" }; |
| 35 | tasks.set(p.name, task); |
| 36 | return send(200, task); |
| 37 | } |
| 38 | if (req.method === "DELETE") { tasks.delete(name); return send(204, null); } |
| 39 | send(405, null); |
| 40 | }); |
| 41 | }); |
| 42 | return new Promise((resolve) => server.listen(sock, () => resolve({ server, tasks, log }))); |
| 43 | } |
| 44 | |
| 45 | test("expiry is capped at 5 minutes", () => { |
| 46 | assert.equal(expireSeconds("5m"), 300); |
| 47 | assert.equal(expireSeconds("90s"), 90); |
| 48 | assert.throws(() => expireSeconds("1h")); |
| 49 | assert.throws(() => expireSeconds("6m")); |
| 50 | assert.throws(() => expireSeconds("10s")); |
| 51 | assert.throws(() => createTaskHold({ name: "Bad Name" })); |
| 52 | assert.throws(() => createTaskHold({ name: "turn-1", expire: "60s", refreshMs: 60_000 }), /shorter/); |
| 53 | }); |
| 54 | |
| 55 | test("acquire registers, refresh re-registers, release deletes", UNIX_SOCKETS, async (t) => { |
| 56 | const sock = path.join(dir, "api1.sock"); |
| 57 | const api = await fakeApi(sock); |
| 58 | t.after(() => api.server.close()); |
| 59 | const events = []; |
| 60 | const hold = createTaskHold({ name: "turn-abc", refreshMs: 30, socket: sock, onEvent: (e) => events.push(e.event) }); |
| 61 | await hold.acquire(); |
| 62 | assert.equal(api.tasks.get("turn-abc").expire, "300s"); |
| 63 | await new Promise((r) => setTimeout(r, 80)); |
| 64 | await hold.release(); |
| 65 | assert.equal(api.tasks.size, 0); |
| 66 | assert.ok(events.includes("refreshed")); |
| 67 | assert.equal(events[0], "acquired"); |
| 68 | assert.equal(events.at(-1), "released"); |
| 69 | assert.ok(api.log.every((l) => l.endsWith("host=sprite"))); |
| 70 | assert.ok(api.log.some((l) => l.startsWith("PUT /v1/tasks/turn-abc"))); |
| 71 | }); |
| 72 | |
| 73 | test("refresh falls back to POST when PUT is not offered", UNIX_SOCKETS, async (t) => { |
| 74 | const sock = path.join(dir, "api2.sock"); |
| 75 | const api = await fakeApi(sock, { putStatus: 405 }); |
| 76 | t.after(() => api.server.close()); |
| 77 | const events = []; |
| 78 | const hold = createTaskHold({ name: "turn-x", refreshMs: 30, socket: sock, onEvent: (e) => events.push(e.event) }); |
| 79 | await hold.acquire(); |
| 80 | await new Promise((r) => setTimeout(r, 70)); |
| 81 | await hold.release(); |
| 82 | assert.ok(events.includes("refreshed")); |
| 83 | assert.ok(!events.includes("refresh_failed")); |
| 84 | }); |
| 85 | |
| 86 | test("turn-hold CLI holds for the turn and releases on stdin EOF (parent gone)", UNIX_SOCKETS, async (t) => { |
| 87 | const sock = path.join(dir, "api3.sock"); |
| 88 | const api = await fakeApi(sock); |
| 89 | t.after(() => api.server.close()); |
| 90 | const child = spawn(process.execPath, [path.join(ROOT, "mcp/turn-hold.mjs"), "--name", "turn-cli", "--socket", sock], { stdio: ["pipe", "pipe", "inherit"] }); |
| 91 | let out = ""; |
| 92 | child.stdout.on("data", (c) => { out += c; }); |
| 93 | for (let i = 0; i < 50 && !out.includes("acquired"); i++) await new Promise((r) => setTimeout(r, 20)); |
| 94 | assert.ok(api.tasks.has("turn-cli"), "held while the turn runs"); |
| 95 | child.stdin.end(); |
| 96 | const [code] = await once(child, "exit"); |
| 97 | assert.equal(code, 0); |
| 98 | assert.equal(api.tasks.size, 0, "released at turn end"); |
| 99 | assert.match(out, /"event":"released"/); |
| 100 | const listed = await spriteApi("GET", "/v1/tasks", null, { socket: sock }); |
| 101 | assert.deepEqual(listed.body.tasks, []); |
| 102 | }); |
| 103 | |
| 104 | test("turn-hold CLI refuses a long expiry and reports an unreachable socket", async () => { |
| 105 | const run = (args) => new Promise((resolve) => { |
| 106 | const c = spawn(process.execPath, [path.join(ROOT, "mcp/turn-hold.mjs"), ...args], { stdio: ["ignore", "pipe", "ignore"] }); |
| 107 | let out = ""; c.stdout.on("data", (d) => { out += d; }); |
| 108 | c.on("exit", (code) => resolve({ code, out })); |
| 109 | }); |
| 110 | let r = await run(["--name", "turn-1", "--expire", "1h", "--socket", path.join(dir, "none.sock")]); |
| 111 | assert.equal(r.code, 2); |
| 112 | assert.match(r.out, /"event":"refused"/); |
| 113 | r = await run(["--name", "turn-1", "--socket", path.join(dir, "none.sock")]); |
| 114 | assert.equal(r.code, 1); |
| 115 | assert.match(r.out, /"event":"acquire_failed"/); |
| 116 | }); |
| 117 |