mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
A coordinator can leave text for a worker without typing into its pane. - Store (src/web/agent-inbox.ts): read until ack by id, seen only for the receiver, 16 KiB/message, 512 KiB + 200 messages per inbox, full = 409 CONFLICT (never drops the oldest), 12 h TTL from each message's createdAt. - Routes (routes/inbox-routes.ts): POST/GET /api/sessions/:id/inbox (?wait= parks while nothing is unseen), POST .../inbox/ack (receiver only, 403 otherwise), GET /api/agent-inbox/summary (owner-scoped counts, not in toState()). Caller = resolveParentSessionId(). - Inbox waiters are the wait registry's third kind: same caps, ended by cancelAll/cancelEverything. - Opt-in snapshot (agentInboxPersistEnabled, synced, default off): agent-inbox.json via dataPath(), 0600 tmp+rename, off deletes it, boot prune only after a successful restore. Dropped on delete, kept on detach. - CLI: codeman agent post | inbox [--wait] | ack, INBOX column in ls. - waitCapacityResponse/abortOnClientHangUp moved to route-helpers.ts unchanged. - Docs: api-reference, README, CLAUDE.md, architecture-invariants, skill + plugin mirror. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
/**
|
|
* @fileoverview Opt-in agent inbox snapshot (src/web/agent-inbox-persistence.ts).
|
|
*
|
|
* Off writes nothing, switching off deletes the file and keeps memory, on writes 0600
|
|
* through tmp + rename, and a boot with the setting on restores what was pending.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { AgentInbox } from '../src/web/agent-inbox.js';
|
|
import { AgentInboxPersistence } from '../src/web/agent-inbox-persistence.js';
|
|
|
|
describe('AgentInboxPersistence', () => {
|
|
let dir: string;
|
|
let file: string;
|
|
let inbox: AgentInbox;
|
|
let persistence: AgentInboxPersistence;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'codeman-inbox-'));
|
|
file = join(dir, 'agent-inbox.json');
|
|
inbox = new AgentInbox();
|
|
// Debounce 0 so a test can wait one macrotask instead of sleeping.
|
|
persistence = new AgentInboxPersistence(inbox, () => file, 0);
|
|
});
|
|
|
|
afterEach(() => {
|
|
persistence.dispose();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const tick = () => new Promise((r) => setTimeout(r, 5));
|
|
|
|
it('writes nothing while off (the default)', async () => {
|
|
persistence.load(false);
|
|
inbox.post('s', null, 'hello');
|
|
await tick();
|
|
expect(existsSync(file)).toBe(false);
|
|
});
|
|
|
|
it('writes a 0600 snapshot when on, and no tmp file is left behind', async () => {
|
|
persistence.load(true);
|
|
inbox.post('s', null, 'hello');
|
|
await tick();
|
|
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s[0].text).toBe('hello');
|
|
expect(statSync(file).mode & 0o777).toBe(0o600);
|
|
expect(existsSync(`${file}.tmp`)).toBe(false);
|
|
});
|
|
|
|
it('switching on writes the current inboxes at once', () => {
|
|
persistence.load(false);
|
|
inbox.post('s', null, 'posted while off');
|
|
persistence.setEnabled(true);
|
|
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
|
|
});
|
|
|
|
it('switching off deletes the snapshot and keeps the in-memory inboxes', async () => {
|
|
persistence.load(true);
|
|
inbox.post('s', null, 'hello');
|
|
await tick();
|
|
expect(existsSync(file)).toBe(true);
|
|
persistence.setEnabled(false);
|
|
expect(existsSync(file)).toBe(false);
|
|
expect(inbox.pendingCount('s')).toBe(1);
|
|
inbox.post('s', null, 'after off');
|
|
await tick();
|
|
expect(existsSync(file)).toBe(false);
|
|
});
|
|
|
|
it('a boot with the setting on restores pending mail', async () => {
|
|
persistence.load(true);
|
|
inbox.post('s', 'sender', 'survives');
|
|
await tick();
|
|
persistence.dispose();
|
|
|
|
const next = new AgentInbox();
|
|
const nextPersistence = new AgentInboxPersistence(next, () => file, 0);
|
|
expect(nextPersistence.load(true)).toBe(1);
|
|
expect(next.read('s', { receiver: false }).messages[0]).toMatchObject({ from: 'sender', text: 'survives' });
|
|
nextPersistence.dispose();
|
|
});
|
|
|
|
it('a boot with the setting off removes a file left from earlier and restores nothing', () => {
|
|
writeFileSync(file, JSON.stringify({ version: 1, inboxes: { s: [] } }));
|
|
expect(persistence.load(false)).toBe(0);
|
|
expect(existsSync(file)).toBe(false);
|
|
});
|
|
|
|
it('an unreadable file is an empty inbox, not a failed boot', () => {
|
|
writeFileSync(file, '{not json');
|
|
expect(persistence.load(true)).toBe(0);
|
|
});
|
|
|
|
it('flush writes a pending change before shutdown', () => {
|
|
const slow = new AgentInboxPersistence(inbox, () => file, 60_000);
|
|
slow.load(true);
|
|
inbox.post('s', null, 'just before shutdown');
|
|
expect(existsSync(file)).toBe(false);
|
|
slow.flush();
|
|
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
|
|
slow.dispose();
|
|
});
|
|
});
|