tests/coop.test.js (9705 bytes)
1 import assert from 'node:assert/strict'; 2 import { spawn } from 'node:child_process'; 3 import fs from 'node:fs'; 4 import http from 'node:http'; 5 import os from 'node:os'; 6 import path from 'node:path'; 7 import { afterEach, beforeEach, test } from 'node:test'; 8 import fc from 'fast-check'; 9 import * as config from '../src/config.js'; 10 import * as coop from '../src/coop.js'; 11 import * as store from '../src/store.js'; 12 13 const TOKEN = 'test-operator-token'; 14 let hub; // a fake coop hub: { url, sent, answers, messages, close } 15 let logs; 16 17 /** A coop hub with the two operator routes that ideamine uses. `answers` are the replies to the next sends. */ 18 function startHub() { 19 const h = { sent: [], answers: [], messages: [], auth: [] }; 20 const server = http.createServer((req, res) => { 21 const chunks = []; 22 req.on('data', (c) => chunks.push(c)); 23 req.on('end', () => { 24 h.auth.push(req.headers.authorization); 25 const reply = (status, json) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(json)); 26 const u = new URL(req.url, 'http://hub'); 27 if (req.headers.authorization !== `Bearer ${TOKEN}`) return reply(401, { error: 'unauthorized' }); 28 if (req.method === 'POST' && u.pathname === '/v1/admin/sessions/workers/messages') { 29 h.sent.push(JSON.parse(Buffer.concat(chunks).toString())); 30 const [status, json] = h.answers.shift() || [404, { error: 'not_found', message: 'no peer in this session can take work now' }]; 31 return reply(status, json); 32 } 33 if (req.method === 'GET' && u.pathname === '/v1/admin/sessions/workers/messages') { 34 const after = Number(u.searchParams.get('after')); 35 return reply(200, { messages: h.messages.filter((m) => Number(m.id) > after).slice(0, Number(u.searchParams.get('limit'))) }); 36 } 37 reply(404, { error: 'not_found' }); 38 }); 39 }); 40 return new Promise((resolve) => 41 server.listen(0, '127.0.0.1', () => resolve({ ...h, h, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })), 42 ); 43 } 44 45 beforeEach(async () => { 46 process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-coop-')); 47 hub = await startHub(); 48 process.env.IDEAMINE_COOP_URL = hub.url; 49 process.env.IDEAMINE_COOP_TOKEN = TOKEN; 50 logs = []; 51 }); 52 53 afterEach(async () => { 54 await hub.close(); 55 delete process.env.IDEAMINE_COOP_URL; 56 delete process.env.IDEAMINE_COOP_TOKEN; 57 }); 58 59 const round = () => coop.round(coop.settings(), { log: (line) => logs.push(line) }); 60 const idea = (id) => store.findIdea(store.load(), id); 61 const lastNote = (id) => idea(id).notes.at(-1).text; 62 63 /** An idea that the user dropped in Doing on a board with coop, through the page's own dropCommand. */ 64 function dropInDoing(text = 'build the thing') { 65 const [{ idea: added }] = store.addIdeas(text); 66 store.applyTriage([{ id: added.id, verdict: 'do', brief: 'the plan', model: 'sonnet' }]); 67 const page = fs.readFileSync(new URL('../dashboard/index.html', import.meta.url), 'utf8'); 68 const dropCommand = new Function('state', `${/function dropCommand[\s\S]*?\n}\n/.exec(page)[0]}; return dropCommand;`)({ data: { live: { coop: true } } }); 69 const command = dropCommand({ ...added, lane: 'do' }, 'doing'); 70 assert.deepEqual(command, { action: 'update', body: { id: added.id, status: 'doing', agent: 'any' } }); 71 const { id, ...patch } = command.body; 72 store.updateIdea(id, patch); 73 return id; 74 } 75 76 test('coop is off without a hub url or without a token, the token is never a setting, and only Doing waits for an agent', () => { 77 assert.ok(coop.settings()); 78 assert.ok(!Object.keys(config.SETTINGS).some((key) => key.includes('token'))); 79 delete process.env.IDEAMINE_COOP_TOKEN; 80 assert.equal(coop.settings(), null); 81 process.env.IDEAMINE_COOP_TOKEN = TOKEN; 82 delete process.env.IDEAMINE_COOP_URL; 83 assert.equal(coop.settings(), null); 84 const [{ idea: i }] = store.addIdeas('x'); 85 assert.throws(() => store.updateIdea(i.id, { agent: 'any' }), /only an idea in Doing can wait for an agent/); 86 assert.throws(() => store.updateIdea(i.id, { status: 'doing', agent: 'claude@g' }), /agent must be "any"/); 87 }); 88 89 test('a card dropped in Doing goes to a free agent, shows its name, and DONE closes it', async () => { 90 const id = dropInDoing(); 91 assert.equal(lastNote(id), 'waits for a free coop agent'); 92 hub.h.answers.push([200, { id: '101', to: 'claude@gentoo', sent_at: 'now' }]); 93 await round(); 94 assert.equal(hub.h.sent.length, 1); 95 assert.equal(hub.h.sent[0].to, 'any'); 96 assert.match(hub.h.sent[0].text, /^Card IDEA-1 of jose's ideamine board: build the thing\n\nbuild the thing\n\nPlan from the triage: the plan\n/); 97 assert.deepEqual([idea(id).status, idea(id).agent.name, idea(id).agent.msg], ['doing', 'claude@gentoo', '101']); 98 assert.equal(lastNote(id), 'coop gave it to claude@gentoo'); 99 100 await round(); // no answer yet: nothing changes, and nothing else is sent 101 assert.equal(idea(id).agent.name, 'claude@gentoo'); 102 assert.equal(hub.h.sent.length, 1); 103 104 hub.h.messages.push({ id: '102', from: 'claude@gentoo', to: 'operator', reply_to: '101', text: 'DONE: pushed abc123 to main' }); 105 await round(); 106 assert.equal(store.lane(idea(id)), 'done'); 107 assert.equal(idea(id).agent, undefined); 108 assert.equal(lastNote(id), 'claude@gentoo: DONE: pushed abc123 to main'); 109 assert.ok(hub.h.auth.every((a) => a === `Bearer ${TOKEN}`)); 110 assert.ok(logs.every((line) => !line.includes(TOKEN)), 'the token stays out of the log'); 111 }); 112 113 test('an answer without DONE puts the card back in Do, with the answer as a note', async () => { 114 const id = dropInDoing(); 115 hub.h.answers.push([200, { id: '7', to: 'codex@gentoo' }]); 116 await round(); 117 hub.h.messages.push({ id: '8', from: 'codex@gentoo', reply_to: '7', text: 'FAILED: the project is not on this machine' }); 118 await round(); 119 assert.equal(store.lane(idea(id)), 'do'); 120 assert.equal(lastNote(id), 'codex@gentoo: FAILED: the project is not on this machine'); 121 }); 122 123 test('when no agent is free, the card waits and goes in a later round', async () => { 124 const id = dropInDoing(); 125 await round(); // the hub answers 404 126 assert.equal(idea(id).agent.name, 'any'); 127 assert.equal(logs.length, 0, 'a busy pool is normal, not an error'); 128 hub.h.answers.push([200, { id: '5', to: 'claude@subread' }]); 129 await round(); 130 assert.equal(idea(id).agent.name, 'claude@subread'); 131 }); 132 133 test('a card that the user moves out of Doing is free of its agent, and a late answer changes nothing', async () => { 134 const id = dropInDoing(); 135 hub.h.answers.push([200, { id: '3', to: 'claude@honjimaku' }]); 136 await round(); 137 store.updateIdea(id, { status: 'reopen' }); 138 assert.equal(idea(id).agent, undefined); 139 hub.h.messages.push({ id: '4', from: 'claude@honjimaku', reply_to: '3', text: 'DONE: late' }); 140 await round(); 141 assert.equal(store.lane(idea(id)), 'do'); 142 assert.notEqual(lastNote(id), 'claude@honjimaku: DONE: late'); 143 }); 144 145 test('a card without an answer for 3 hours goes back in Do', async () => { 146 const id = dropInDoing(); 147 hub.h.answers.push([200, { id: '9', to: 'claude@gentoo' }]); 148 await round(); 149 store.mutate((db) => (store.findIdea(db, id).agent.at = new Date(Date.now() - 4 * 3600000).toISOString())); 150 await round(); 151 assert.equal(store.lane(idea(id)), 'do'); 152 assert.equal(lastNote(id), 'no answer from claude@gentoo in 3 h'); 153 }); 154 155 test('claim takes each waiting card once, oldest first, and never another card: a property', () => { 156 fc.assert( 157 fc.property(fc.array(fc.constantFrom('waits', 'doing', 'do', 'inbox', 'done'), { maxLength: 12 }), (kinds) => { 158 process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-claim-')); 159 const waits = []; 160 kinds.forEach((kind, n) => { 161 const [{ idea: i }] = store.addIdeas(`idea ${n}`); 162 if (kind === 'do') store.applyTriage([{ id: i.id, verdict: 'do' }]); 163 if (['waits', 'doing', 'done'].includes(kind)) store.updateIdea(i.id, { status: kind === 'done' ? 'done' : 'doing' }); 164 if (kind === 'waits') { 165 store.updateIdea(i.id, { agent: 'any' }); 166 store.mutate((db) => (store.findIdea(db, i.id).agent.at = new Date(Date.UTC(2026, 0, 1, 0, 0, 59 - n)).toISOString())); 167 waits.push(i.id); 168 } 169 }); 170 const taken = []; 171 for (let c; (c = coop.claim()); ) taken.push(c.id); 172 assert.deepEqual(taken, waits.reverse()); // the later idea waits longer here 173 assert.ok(store.load().ideas.every((i) => (waits.includes(i.id) ? i.agent.name === 'coop' : !i.agent))); 174 }), 175 { numRuns: 60 }, 176 ); 177 }); 178 179 test('claims from many processes at once never give one card twice', async () => { 180 const cards = 24; 181 for (let n = 0; n < cards; n++) { 182 const [{ idea: i }] = store.addIdeas(`card ${n}`); 183 store.updateIdea(i.id, { status: 'doing', agent: 'any' }); 184 } 185 const mod = new URL('../src/coop.js', import.meta.url).href; 186 const run = () => 187 new Promise((resolve, reject) => { 188 const script = `const c = await import(${JSON.stringify(mod)}); const got = []; for (let x; (x = c.claim()); ) got.push(x.id); console.log(JSON.stringify(got));`; 189 const child = spawn(process.execPath, ['--input-type=module', '-e', script], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); 190 let out = ''; 191 let err = ''; 192 child.stdout.on('data', (d) => (out += d)); 193 child.stderr.on('data', (d) => (err += d)); 194 child.on('close', (code) => (code === 0 ? resolve(JSON.parse(out)) : reject(new Error(err)))); 195 }); 196 const all = (await Promise.all(Array.from({ length: 6 }, run))).flat(); 197 assert.equal(all.length, cards); 198 assert.equal(new Set(all).size, cards); 199 });