Files
openchamber/packages/web/server/lib/message-queue/runtime.js
T
Bohdan Triapitsyn 34bf631157 feat(queue): queue a message with everything the composer had attached
Queueing captured only the text and files. Context chips (inline comments,
terminal selections, browser annotations, PR comments and checks, quotes,
linked issue/PR/Linear references, pending synthetic parts) stayed in the
composer and only left with the next manual send, so a queued message the
server delivered went out without them and the chips rode an unrelated
message later.

A queued message now carries what the composer would have sent: the text
with its agent mention stripped and file mentions resolved into
attachments, the attached context as structured parts, and the skill
instruction derived from the text. The server delivers those parts in the
composer's order, the VS Code auto-send does the same, and editing a queued
message puts the chips and linked references back. A failed queue restores
the composer completely. Snapshots and broadcasts omit the captured
context like attachment payloads; a take returns it.

Claude-Session: https://claude.ai/code/session_01HB9wdLQoZX2vfyDjwv6Rso
2026-09-04 22:56:27 +03:00

819 lines
32 KiB
JavaScript

// Server-owned message queue: messages the user queued while a session was
// busy, delivered by the web server the moment the session goes idle. The
// queue lives here, not in the browser, so closing the tab, locking the phone,
// or losing the connection no longer strands what was queued. Structural
// template: permission-auto-accept (server-authoritative state, UI as a
// projection, VS Code keeps its own foreground implementation).
//
// Event-driven like session-goal: the shared upstream hub delivers
// `session.status`, and an idle transition arms a short per-session timer. The
// tick re-verifies idleness against OpenCode (status map + message tail) before
// it sends, because a queued prompt sent into a running turn would be steered
// into it instead of starting the next one.
import fs from 'fs';
import path from 'path';
const QUEUE_FILE_NAME = 'message-queue.json';
const QUEUE_FILE_VERSION = 1;
const MAX_SESSIONS = 50;
const MAX_ITEMS_PER_SESSION = 20;
const CONTENT_CHAR_LIMIT = 200_000;
// Idle events arrive in bursts around a turn boundary; a short quiet window
// coalesces them before the tick verifies idleness against OpenCode.
const DISPATCH_QUIET_MS = 500;
// After a user abort the UI held the queue for two seconds so the stop is not
// immediately followed by the next prompt; the server keeps that window.
const ABORT_HOLD_MS = 2_000;
const RETRY_BASE_DELAY_MS = 2_000;
const RETRY_MAX_DELAY_MS = 60_000;
// A hold is asserted by a UI-driven process (auto-review) that dies with the
// UI; it expires unless the UI keeps re-asserting it.
const HOLD_DEFAULT_TTL_MS = 5 * 60 * 1000;
const HOLD_MAX_TTL_MS = 10 * 60 * 1000;
const FETCH_TIMEOUT_MS = 15_000;
const MESSAGE_TAIL_LIMIT = 2;
const ATTACHMENT_SOURCES = new Set(['local', 'server', 'vscode']);
// Context captured with a queued message (see QueuedContextPart in the UI
// store): attached context items carry metadata the timeline renders back;
// the other kinds are plain synthetic text.
const CONTEXT_PART_KINDS = new Set(['context', 'instruction', 'synthetic']);
const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,128}$/;
const getQueuedSendRetryDelayMs = (failures) =>
Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.max(failures - 1, 0), RETRY_MAX_DELAY_MS);
// Boundary readers: the only place raw JSON (client bodies, the queue file,
// OpenCode responses, hub events) is inspected. Everything below them
// branches on the domain values they return.
const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
const asText = (value) => (typeof value === 'string' ? value : '');
const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null);
const asList = (value) => (Array.isArray(value) ? value : null);
const asCount = (value) => (Number.isFinite(value) && value >= 0 ? Math.floor(value) : null);
const isValidSessionId = (value) => SESSION_ID_PATTERN.test(asNonEmptyString(value));
const httpError = (message, status) => Object.assign(new Error(message), { status });
const parseSendConfig = (value) => {
const raw = asRecord(value);
if (!raw) return null;
const providerID = asNonEmptyString(raw.providerID);
const modelID = asNonEmptyString(raw.modelID);
if (!providerID || !modelID) return null;
const sendConfig = { providerID, modelID };
const agent = asNonEmptyString(raw.agent);
if (agent) sendConfig.agent = agent;
const variant = asNonEmptyString(raw.variant);
if (variant) sendConfig.variant = variant;
return sendConfig;
};
const parseAttachment = (value) => {
const raw = asRecord(value);
if (!raw) return null;
const filename = asNonEmptyString(raw.filename);
const mimeType = asNonEmptyString(raw.mimeType);
const dataUrl = asText(raw.dataUrl);
if (!filename || !mimeType || !dataUrl) return null;
const attachment = {
id: asNonEmptyString(raw.id) || `attachment-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
filename,
mimeType,
size: asCount(raw.size) ?? 0,
source: ATTACHMENT_SOURCES.has(raw.source) ? raw.source : 'local',
};
const serverPath = asNonEmptyString(raw.serverPath);
if (serverPath) attachment.serverPath = serverPath;
attachment.dataUrl = dataUrl;
return attachment;
};
const parseContextPart = (value) => {
const raw = asRecord(value);
if (!raw || !CONTEXT_PART_KINDS.has(raw.kind)) return null;
const text = asText(raw.text);
if (raw.kind !== 'context') return { kind: raw.kind, text };
// The metadata is the UI's structured payload; the server only carries it
// to the prompt, so its shape is the UI's to validate on the way back.
const metadata = asRecord(raw.metadata);
if (!metadata) return null;
const part = { kind: 'context', text, metadata };
const instructions = asNonEmptyString(raw.instructions);
if (instructions) part.instructions = instructions;
return part;
};
/**
* Validates a queued item posted by a client. Throws a TypeError (→ 400) for
* anything that could not be delivered later: a queue must never hold an item
* the server cannot send.
*/
export const parseQueuedItemInput = (value) => {
const raw = asRecord(value);
if (!raw) throw new TypeError('item is required');
const content = asText(raw.content).replace(/^\n+|\n+$/g, '');
if (content.length > CONTENT_CHAR_LIMIT) throw new TypeError('item content is too long');
const text = raw.text === undefined ? content : asText(raw.text);
const attachments = (asList(raw.attachments) ?? []).map(parseAttachment);
if (attachments.some((attachment) => attachment === null)) throw new TypeError('invalid attachment');
const context = (asList(raw.context) ?? []).map(parseContextPart);
if (context.some((part) => part === null)) throw new TypeError('invalid context part');
if (!text.trim() && attachments.length === 0 && context.length === 0) {
throw new TypeError('item needs text, attachments, or context');
}
const sendConfig = parseSendConfig(raw.sendConfig);
if (!sendConfig) throw new TypeError('item sendConfig with providerID and modelID is required');
const item = { content, text };
const agentMention = asNonEmptyString(raw.agentMention);
if (agentMention) item.agentMention = agentMention;
item.attachments = attachments;
item.context = context;
item.sendConfig = sendConfig;
return item;
};
const parseStoredItem = (value) => {
const raw = asRecord(value);
const id = raw ? asNonEmptyString(raw.id) : '';
if (!id) return null;
try {
return { id, createdAt: asCount(raw.createdAt) ?? Date.now(), ...parseQueuedItemInput(raw) };
} catch {
return null;
}
};
const toPublicAttachment = ({ dataUrl: _dataUrl, ...attachment }) => attachment;
// What clients see: everything except the payloads — attachment data URLs
// (megabytes of base64) and captured context (a PR diff, say) — which would
// otherwise ride every broadcast. A take hands the full item back.
const toPublicItem = (item) => {
const publicItem = { id: item.id, createdAt: item.createdAt, content: item.content, text: item.text };
if (item.agentMention) publicItem.agentMention = item.agentMention;
publicItem.attachments = item.attachments.map(toPublicAttachment);
publicItem.sendConfig = { ...item.sendConfig };
return publicItem;
};
const extractSessionStatus = (payload) => {
if (payload.type !== 'session.status') return null;
const properties = asRecord(payload.properties) ?? {};
const status = asRecord(properties.status) ?? {};
const info = asRecord(properties.info) ?? {};
const sessionId = asNonEmptyString(properties.sessionID);
const type = asNonEmptyString(status.type) || asNonEmptyString(info.type);
if (!sessionId || !type) return null;
return { sessionId, type };
};
const extractAssistantMessageUpdate = (payload) => {
if (payload.type !== 'message.updated') return null;
const info = asRecord(asRecord(payload.properties)?.info);
if (!info || info.role !== 'assistant') return null;
const sessionId = asNonEmptyString(info.sessionID);
if (!sessionId) return null;
return {
sessionId,
aborted: asRecord(info.error)?.name === 'MessageAbortedError',
completed: asCount(asRecord(info.time)?.completed) !== null,
};
};
const extractDeletedSessionId = (payload) => {
if (payload.type !== 'session.deleted') return null;
const properties = asRecord(payload.properties) ?? {};
return asNonEmptyString(asRecord(properties.info)?.id) || asNonEmptyString(properties.sessionID) || null;
};
export function createMessageQueueRuntime({
globalEventHub,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
sessionKnowledgeRuntime = null,
broadcastGlobalUiEvent,
onPromptSent,
dataDir,
fetchImpl = fetch,
now = Date.now,
dispatchQuietMs = DISPATCH_QUIET_MS,
abortHoldMs = ABORT_HOLD_MS,
retryDelayMs = getQueuedSendRetryDelayMs,
}) {
const filePath = path.join(dataDir, QUEUE_FILE_NAME);
/** sessionId → { directory, items } */
const queues = new Map();
let revision = 0;
let loadPromise = null;
let writePromise = Promise.resolve();
let stopped = false;
/** In-memory only — a restart has no in-flight sends. */
const sending = new Map(); // sessionId → itemId
const timers = new Map(); // sessionId → timeout
const failures = new Map(); // sessionId → { itemId, failures, nextAttemptAt }
const abortedAt = new Map(); // sessionId → timestamp
const holds = new Map(); // sessionId → expiresAt
// --- persistence ---------------------------------------------------------
const serialize = () => ({
version: QUEUE_FILE_VERSION,
revision,
sessions: Object.fromEntries(
Array.from(queues.entries()).map(([sessionId, queue]) => [sessionId, { directory: queue.directory, items: queue.items }]),
),
});
const readFile = async () => {
let raw;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if (asRecord(error)?.code === 'ENOENT') return { sessions: {}, revision: 0 };
throw error;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
// Malformed is a failure, not an empty queue: keep the bytes for the
// user and start over rather than overwriting them on the next write.
const backup = `${filePath}.corrupt-${now()}`;
await fs.promises.rename(filePath, backup).catch(() => undefined);
console.warn(`[message-queue] queue file was unreadable and moved to ${backup}: ${error?.message ?? error}`);
return { sessions: {}, revision: 0 };
}
const stored = asRecord(parsed) ?? {};
const sessions = {};
for (const [sessionId, value] of Object.entries(asRecord(stored.sessions) ?? {})) {
const entry = asRecord(value);
if (!entry || !isValidSessionId(sessionId)) continue;
const directory = asNonEmptyString(entry.directory);
const items = (asList(entry.items) ?? []).map(parseStoredItem).filter(Boolean);
if (!directory || items.length === 0) continue;
sessions[sessionId] = { directory, items };
}
return { sessions, revision: asCount(stored.revision) ?? 0 };
};
const load = () => {
if (!loadPromise) {
loadPromise = readFile()
.then((stored) => {
for (const [sessionId, entry] of Object.entries(stored.sessions)) queues.set(sessionId, entry);
revision = Math.max(revision, stored.revision);
})
.catch((error) => {
// A read failure keeps the in-memory (empty) queue but must not be
// mistaken for "nothing queued": the next write would clobber the
// file, so writes stay disabled until a later load succeeds.
loadPromise = null;
throw error;
});
}
return loadPromise;
};
const persist = () => {
const payload = JSON.stringify(serialize());
writePromise = writePromise
.then(async () => {
await fs.promises.mkdir(dataDir, { recursive: true });
const tmpPath = `${filePath}.${process.pid}.tmp`;
await fs.promises.writeFile(tmpPath, payload, 'utf8');
await fs.promises.rename(tmpPath, filePath);
})
.catch((error) => {
console.warn('[message-queue] failed to persist queue:', error?.message ?? error);
});
return writePromise;
};
// --- snapshots -----------------------------------------------------------
const sessionSnapshot = (sessionId) => {
const queue = queues.get(sessionId);
return {
sessionId,
directory: queue?.directory ?? '',
items: (queue?.items ?? []).map(toPublicItem),
sendingId: sending.get(sessionId) ?? null,
};
};
const snapshot = () => ({
revision,
sessions: Array.from(queues.keys()).map(sessionSnapshot),
});
const broadcast = (sessionId) => {
broadcastGlobalUiEvent?.({
type: 'openchamber:message-queue.updated',
properties: { revision, session: sessionSnapshot(sessionId) },
});
};
/** Every mutation goes through here: bump, persist, broadcast. */
const commit = (sessionId) => {
revision += 1;
void persist();
broadcast(sessionId);
return { revision, session: sessionSnapshot(sessionId) };
};
const setQueueItems = (sessionId, directory, items) => {
if (items.length === 0) {
queues.delete(sessionId);
return;
}
queues.set(sessionId, { directory, items });
};
// --- OpenCode access -----------------------------------------------------
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
const base = buildOpenCodeUrl(fetchPath, '');
const params = new URLSearchParams(query || {});
if (directory) params.set('directory', directory);
const search = params.toString();
const headers = { Accept: 'application/json', ...getOpenCodeAuthHeaders() };
const init = { method, headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) };
if (body) {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const response = await fetchImpl(search ? `${base}?${search}` : base, init);
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw httpError(`OpenCode ${method} ${fetchPath} failed with ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`, response.status);
}
return response.json().catch(() => null);
};
/**
* Live idleness, or null when it could not be established. Unknown is never
* idle: a fetch failure re-arms instead of sending into a running turn.
*/
const isSessionIdle = async (sessionId, directory) => {
const statuses = asRecord(await openCodeFetch('/session/status', { directory }).catch(() => null));
if (!statuses) return null;
const type = asRecord(statuses[sessionId])?.type;
if (type === 'busy' || type === 'retry') return false;
// The status map lists only busy sessions, so a missed busy event leaves
// no entry while a turn still streams. The trailing unfinished assistant
// message is the live evidence of that turn (mirrors the UI gate).
const messages = asList(await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
directory,
query: { limit: String(MESSAGE_TAIL_LIMIT) },
}).catch(() => null));
if (!messages) return null;
const last = asRecord(asRecord(messages[messages.length - 1])?.info);
if (last?.role === 'assistant' && asCount(asRecord(last.time)?.completed) === null) return false;
return true;
};
const resolveSlashCommand = async (text, directory) => {
if (!text.startsWith('/')) return null;
const [head, ...tail] = text.split(' ');
const name = head.slice(1);
if (!name) return null;
const commands = asList(await openCodeFetch('/command', { directory })) ?? [];
if (!commands.some((command) => asRecord(command)?.name === name)) return null;
return { name, arguments: tail.join(' ') };
};
const toFilePart = (attachment) => ({
type: 'file',
mime: attachment.mimeType,
filename: attachment.filename,
url: attachment.dataUrl,
});
// Captured context is delivered the way the composer delivers it: one
// synthetic text part per entry, an attached item's metadata riding along
// and its reading instructions (a linked PR) going first.
const toContextParts = (part) => {
const synthetic = { type: 'text', text: part.text, synthetic: true };
if (part.kind !== 'context') return [synthetic];
synthetic.metadata = part.metadata;
return part.instructions
? [{ type: 'text', text: part.instructions, synthetic: true }, synthetic]
: [synthetic];
};
const sendItem = async (sessionId, directory, item) => {
const { providerID, modelID, agent, variant } = item.sendConfig;
const fileParts = item.attachments.map(toFilePart);
const contextParts = item.context.flatMap(toContextParts);
const command = await resolveSlashCommand(item.text, directory);
if (command) {
const body = { command: command.name, arguments: command.arguments, model: `${providerID}/${modelID}` };
if (agent) body.agent = agent;
if (variant) body.variant = variant;
const extraParts = [...fileParts, ...contextParts];
if (extraParts.length > 0) body.parts = extraParts;
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body });
return;
}
// Standing project context rides the prompt exactly as a UI send would
// attach it; a failed lookup sends without it rather than not at all.
const knowledge = sessionKnowledgeRuntime
? await sessionKnowledgeRuntime.resolvePendingForSession(sessionId, directory)
.catch(() => ({ text: '', signature: '' }))
: { text: '', signature: '' };
// Same order as a UI send: the user's text and files, the context queued
// with them, then the standing context, then the mentioned agent.
const parts = [];
if (item.text.trim()) parts.push({ type: 'text', text: item.text });
parts.push(...fileParts);
parts.push(...contextParts);
if (knowledge.text) parts.push({ type: 'text', text: knowledge.text, synthetic: true });
if (item.agentMention) parts.push({ type: 'agent', name: item.agentMention });
const body = { model: { providerID, modelID } };
if (agent) body.agent = agent;
if (variant) body.variant = variant;
body.parts = parts;
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, { directory, method: 'POST', body });
if (knowledge.text && sessionKnowledgeRuntime) {
// After the prompt is accepted, so a rejected dispatch carries it again.
await sessionKnowledgeRuntime.recordDelivered(sessionId, directory, knowledge.signature).catch(() => undefined);
}
};
// --- dispatch loop -------------------------------------------------------
const clearTimer = (sessionId) => {
const existing = timers.get(sessionId);
if (existing) {
clearTimeout(existing);
timers.delete(sessionId);
}
};
const armDispatch = (sessionId, delayMs = dispatchQuietMs) => {
if (stopped || !queues.has(sessionId)) return;
clearTimer(sessionId);
const timer = setTimeout(() => {
timers.delete(sessionId);
tick(sessionId).catch((error) => {
console.warn('[message-queue] dispatch tick failed:', error?.message ?? error);
});
}, Math.max(0, delayMs));
timer.unref?.();
timers.set(sessionId, timer);
};
const isHeld = (sessionId) => {
const expiresAt = holds.get(sessionId);
if (expiresAt === undefined) return false;
if (expiresAt > now()) return true;
holds.delete(sessionId);
return false;
};
async function tick(sessionId) {
if (stopped) return;
const queue = queues.get(sessionId);
if (!queue || queue.items.length === 0 || sending.has(sessionId) || isHeld(sessionId)) return;
const abortHoldUntil = (abortedAt.get(sessionId) ?? 0) + abortHoldMs;
if (abortHoldUntil > now()) {
armDispatch(sessionId, abortHoldUntil - now());
return;
}
const head = queue.items[0];
const failure = failures.get(sessionId);
if (failure && failure.itemId !== head.id) failures.delete(sessionId);
else if (failure && failure.nextAttemptAt > now()) {
armDispatch(sessionId, failure.nextAttemptAt - now());
return;
}
const idle = await isSessionIdle(sessionId, queue.directory);
if (idle === null) {
armDispatch(sessionId, retryDelayMs(1));
return;
}
// Busy: the next idle status event re-arms the loop.
if (!idle) return;
// Re-read after the awaits — the user may have edited the queue meanwhile.
const current = queues.get(sessionId);
const item = current?.items[0];
if (!item || item.id !== head.id || sending.has(sessionId)) return;
sending.set(sessionId, item.id);
broadcast(sessionId);
try {
await sendItem(sessionId, current.directory, item);
const after = queues.get(sessionId);
if (after) setQueueItems(sessionId, after.directory, after.items.filter((entry) => entry.id !== item.id));
failures.delete(sessionId);
sending.delete(sessionId);
commit(sessionId);
try {
onPromptSent?.(sessionId);
} catch {
// bookkeeping only
}
console.log(`[message-queue] sent queued message to ${sessionId}`);
} catch (error) {
sending.delete(sessionId);
const count = (failure?.itemId === item.id ? failure.failures : 0) + 1;
const nextAttemptAt = now() + retryDelayMs(count);
failures.set(sessionId, { itemId: item.id, failures: count, nextAttemptAt });
console.warn(`[message-queue] send to ${sessionId} failed (attempt ${count}):`, error?.message ?? error);
broadcast(sessionId);
armDispatch(sessionId, nextAttemptAt - now());
}
}
const reconcileAll = () => {
for (const sessionId of queues.keys()) {
if (!timers.has(sessionId)) armDispatch(sessionId, dispatchQuietMs);
}
};
// --- public mutations ----------------------------------------------------
const requireSessionId = (sessionId) => {
if (!isValidSessionId(sessionId)) throw new TypeError('sessionId is invalid');
return sessionId;
};
const enqueue = async (sessionIdInput, directoryInput, itemInput) => {
const sessionId = requireSessionId(sessionIdInput);
const directory = asNonEmptyString(directoryInput);
if (!directory) throw new TypeError('directory is required');
const parsed = parseQueuedItemInput(itemInput);
await load();
const item = {
id: `queued-${now()}-${Math.random().toString(36).slice(2, 9)}`,
createdAt: now(),
...parsed,
};
const existing = queues.get(sessionId);
const items = [...(existing?.items ?? []), item].slice(-MAX_ITEMS_PER_SESSION);
queues.set(sessionId, { directory, items });
if (queues.size > MAX_SESSIONS) {
const oldest = Array.from(queues.entries())
.filter(([id]) => id !== sessionId && !sending.has(id))
.sort((left, right) => (left[1].items[0]?.createdAt ?? 0) - (right[1].items[0]?.createdAt ?? 0))
.slice(0, queues.size - MAX_SESSIONS);
for (const [staleId] of oldest) {
queues.delete(staleId);
clearTimer(staleId);
broadcast(staleId);
}
}
const result = commit(sessionId);
// The session may already be idle (queued from a busy-looking composer
// right as the turn ended); the tick verifies before sending.
armDispatch(sessionId);
return { ...result, itemId: item.id };
};
const remove = async (sessionIdInput, itemId) => {
const sessionId = requireSessionId(sessionIdInput);
await load();
if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409);
const queue = queues.get(sessionId);
if (!queue || !queue.items.some((item) => item.id === itemId)) {
return { revision, session: sessionSnapshot(sessionId) };
}
setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id !== itemId));
return commit(sessionId);
};
/** Removes the item and hands its full payload (attachments included) back. */
const take = async (sessionIdInput, itemId) => {
const sessionId = requireSessionId(sessionIdInput);
await load();
if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409);
const queue = queues.get(sessionId);
const item = queue?.items.find((entry) => entry.id === itemId);
if (!queue || !item) throw httpError('queued message not found', 404);
setQueueItems(sessionId, queue.directory, queue.items.filter((entry) => entry.id !== itemId));
return { ...commit(sessionId), item };
};
/** Removes every item not currently being sent and hands them back in order. */
const takeAll = async (sessionIdInput) => {
const sessionId = requireSessionId(sessionIdInput);
await load();
const queue = queues.get(sessionId);
if (!queue) return { revision, session: sessionSnapshot(sessionId), items: [] };
const sendingId = sending.get(sessionId) ?? null;
const items = queue.items.filter((item) => item.id !== sendingId);
if (items.length === 0) return { revision, session: sessionSnapshot(sessionId), items: [] };
setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId));
return { ...commit(sessionId), items };
};
const reorder = async (sessionIdInput, itemIds) => {
const sessionId = requireSessionId(sessionIdInput);
if (!asList(itemIds) || itemIds.some((id) => !asNonEmptyString(id))) {
throw new TypeError('itemIds must be a list of ids');
}
await load();
const queue = queues.get(sessionId);
if (!queue) return { revision, session: sessionSnapshot(sessionId) };
const byId = new Map(queue.items.map((item) => [item.id, item]));
if (itemIds.length !== byId.size || new Set(itemIds).size !== itemIds.length || itemIds.some((id) => !byId.has(id))) {
throw new TypeError('itemIds must list every queued message exactly once');
}
queues.set(sessionId, { directory: queue.directory, items: itemIds.map((id) => byId.get(id)) });
return commit(sessionId);
};
const clear = async (sessionIdInput) => {
const sessionId = requireSessionId(sessionIdInput);
await load();
const queue = queues.get(sessionId);
if (!queue) return { revision, session: sessionSnapshot(sessionId) };
// Never drop a message already handed to OpenCode: its send resolves and
// must find its entry.
const sendingId = sending.get(sessionId) ?? null;
setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId));
clearTimer(sessionId);
return commit(sessionId);
};
const setHold = (sessionIdInput, held, ttlMs = HOLD_DEFAULT_TTL_MS) => {
const sessionId = requireSessionId(sessionIdInput);
if (held !== true && held !== false) throw new TypeError('held must be a boolean');
if (held) {
const ttl = Math.min(asCount(ttlMs) || HOLD_DEFAULT_TTL_MS, HOLD_MAX_TTL_MS);
holds.set(sessionId, now() + ttl);
clearTimer(sessionId);
return { held: true, expiresAt: holds.get(sessionId) };
}
holds.delete(sessionId);
armDispatch(sessionId);
return { held: false, expiresAt: null };
};
// --- events --------------------------------------------------------------
const processPayload = (value) => {
const payload = asRecord(value);
if (stopped || !payload) return;
const deletedSessionId = extractDeletedSessionId(payload);
if (deletedSessionId) {
if (!queues.has(deletedSessionId)) return;
queues.delete(deletedSessionId);
clearTimer(deletedSessionId);
failures.delete(deletedSessionId);
commit(deletedSessionId);
return;
}
const status = extractSessionStatus(payload);
if (status) {
if (!queues.has(status.sessionId)) return;
if (status.type === 'idle') armDispatch(status.sessionId);
else clearTimer(status.sessionId);
return;
}
const assistant = extractAssistantMessageUpdate(payload);
if (assistant && queues.has(assistant.sessionId)) {
if (assistant.aborted) abortedAt.set(assistant.sessionId, now());
// A completed reply without a following idle status (missed event)
// must still drain the queue; the tick verifies idleness itself.
if (assistant.completed && !timers.has(assistant.sessionId)) armDispatch(assistant.sessionId);
}
};
const processEvent = (event) => {
const raw = asRecord(asRecord(event)?.payload);
processPayload(asRecord(raw?.payload) ?? raw);
};
const start = () => {
const unsubscribeEvent = globalEventHub.subscribeEvent(processEvent);
const unsubscribeStatus = globalEventHub.subscribeStatus((status) => {
if (status?.type === 'connect') reconcileAll();
});
void load()
.then(() => {
if (queues.size > 0) console.log(`[message-queue] restored queues for ${queues.size} session(s)`);
reconcileAll();
})
.catch((error) => {
console.warn('[message-queue] failed to load queue file:', error?.message ?? error);
});
return () => {
unsubscribeEvent();
unsubscribeStatus();
};
};
const stop = () => {
stopped = true;
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
};
return {
load,
snapshot,
sessionSnapshot,
enqueue,
remove,
take,
takeAll,
reorder,
clear,
setHold,
processPayload,
start,
stop,
/** Drains the pending write; tests and shutdown use it. */
flush: () => writePromise,
};
}
export function registerMessageQueueRoutes(app, runtime) {
const respondError = (res, error, fallback) => {
const status = error instanceof TypeError ? 400 : (Number.isInteger(error?.status) ? error.status : 500);
res.status(status).json({ error: error?.message ?? fallback });
};
app.get('/api/message-queue', async (_req, res) => {
try {
await runtime.load();
res.json(runtime.snapshot());
} catch (error) {
respondError(res, error, 'Failed to load message queue');
}
});
app.post('/api/message-queue/sessions/:sessionId/items', async (req, res) => {
try {
res.json(await runtime.enqueue(req.params.sessionId, req.body?.directory, req.body?.item));
} catch (error) {
respondError(res, error, 'Failed to queue message');
}
});
app.post('/api/message-queue/sessions/:sessionId/take', async (req, res) => {
try {
res.json(await runtime.takeAll(req.params.sessionId));
} catch (error) {
respondError(res, error, 'Failed to take queued messages');
}
});
app.put('/api/message-queue/sessions/:sessionId/order', async (req, res) => {
try {
res.json(await runtime.reorder(req.params.sessionId, req.body?.itemIds));
} catch (error) {
respondError(res, error, 'Failed to reorder queue');
}
});
app.put('/api/message-queue/sessions/:sessionId/hold', async (req, res) => {
try {
await runtime.load();
res.json(runtime.setHold(req.params.sessionId, req.body?.held, req.body?.ttlMs));
} catch (error) {
respondError(res, error, 'Failed to update queue hold');
}
});
app.delete('/api/message-queue/sessions/:sessionId', async (req, res) => {
try {
res.json(await runtime.clear(req.params.sessionId));
} catch (error) {
respondError(res, error, 'Failed to clear queue');
}
});
app.post('/api/message-queue/sessions/:sessionId/items/:itemId/take', async (req, res) => {
try {
res.json(await runtime.take(req.params.sessionId, req.params.itemId));
} catch (error) {
respondError(res, error, 'Failed to take queued message');
}
});
app.delete('/api/message-queue/sessions/:sessionId/items/:itemId', async (req, res) => {
try {
res.json(await runtime.remove(req.params.sessionId, req.params.itemId));
} catch (error) {
respondError(res, error, 'Failed to remove queued message');
}
});
}