feat(dashboard): run paired inbox actions (#34)

This commit is contained in:
Eyejoker
2026-04-27 02:39:41 +09:00
committed by GitHub
parent 1676f53f4d
commit 16aeb0449d
5 changed files with 493 additions and 0 deletions

View File

@@ -349,6 +349,212 @@ describe('web dashboard server handler', () => {
expect(wrongMethod.status).toBe(405);
});
it('queues paired inbox actions through the paired follow-up scheduler', async () => {
const pairedTasks = new Map<string, PairedTask>([
[
'review-1',
makePairedTask({
id: 'review-1',
status: 'review_ready',
updated_at: '2026-04-26T05:01:00.000Z',
}),
],
[
'merge-1',
makePairedTask({
id: 'merge-1',
status: 'merge_ready',
updated_at: '2026-04-26T05:02:00.000Z',
}),
],
[
'arbiter-1',
makePairedTask({
id: 'arbiter-1',
status: 'arbiter_requested',
updated_at: '2026-04-26T05:03:00.000Z',
}),
],
]);
const scheduled: Array<{
chatJid: string;
taskId: string;
intentKind: string;
}> = [];
const queued: Array<{ chatJid: string; groupFolder: string }> = [];
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [...pairedTasks.values()],
getPairedTaskById: (id) => pairedTasks.get(id),
schedulePairedFollowUp: (args) => {
scheduled.push({
chatJid: args.chatJid,
taskId: args.task.id,
intentKind: args.intentKind,
});
args.enqueue();
return true;
},
enqueueMessageCheck: (chatJid, groupFolder) => {
queued.push({ chatJid, groupFolder });
},
});
const run = (inboxId: string) =>
handler(
new Request(
`http://localhost/api/inbox/${encodeURIComponent(inboxId)}/actions`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'run' }),
},
),
);
const reviewer = await run('paired:review-1:review_ready');
const finalize = await run('paired:merge-1:merge_ready');
const arbiter = await run('paired:arbiter-1:arbiter_requested');
expect(reviewer.status).toBe(200);
expect(finalize.status).toBe(200);
expect(arbiter.status).toBe(200);
await expect(reviewer.json()).resolves.toMatchObject({
ok: true,
taskId: 'review-1',
intentKind: 'reviewer-turn',
queued: true,
});
await expect(finalize.json()).resolves.toMatchObject({
ok: true,
taskId: 'merge-1',
intentKind: 'finalize-owner-turn',
queued: true,
});
await expect(arbiter.json()).resolves.toMatchObject({
ok: true,
taskId: 'arbiter-1',
intentKind: 'arbiter-turn',
queued: true,
});
expect(scheduled).toEqual([
{
chatJid: 'dc:general',
taskId: 'review-1',
intentKind: 'reviewer-turn',
},
{
chatJid: 'dc:general',
taskId: 'merge-1',
intentKind: 'finalize-owner-turn',
},
{
chatJid: 'dc:general',
taskId: 'arbiter-1',
intentKind: 'arbiter-turn',
},
]);
expect(queued).toEqual([
{ chatJid: 'dc:general', groupFolder: 'general' },
{ chatJid: 'dc:general', groupFolder: 'general' },
{ chatJid: 'dc:general', groupFolder: 'general' },
]);
});
it('rejects invalid paired inbox actions', async () => {
const pairedTask = makePairedTask({
id: 'paired-1',
status: 'review_ready',
});
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [pairedTask],
getPairedTaskById: (id) =>
id === pairedTask.id ? pairedTask : undefined,
schedulePairedFollowUp: () => {
throw new Error('schedulePairedFollowUp should not be called');
},
enqueueMessageCheck: () => undefined,
});
const post = (inboxId: string, body: unknown = { action: 'run' }) =>
handler(
new Request(
`http://localhost/api/inbox/${encodeURIComponent(inboxId)}/actions`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
},
),
);
const invalidBody = await post('paired:paired-1:review_ready', {
action: 'approve',
});
expect(invalidBody.status).toBe(400);
const unsupportedTarget = await post('ci:task-1');
expect(unsupportedTarget.status).toBe(400);
const missingTask = await post('paired:missing:review_ready');
expect(missingTask.status).toBe(404);
const stale = await post('paired:paired-1:merge_ready');
expect(stale.status).toBe(409);
const completedHandler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
getPairedTaskById: () =>
makePairedTask({ id: 'done-1', status: 'completed' }),
schedulePairedFollowUp: () => {
throw new Error('schedulePairedFollowUp should not be called');
},
enqueueMessageCheck: () => undefined,
});
const completed = await completedHandler(
new Request(
'http://localhost/api/inbox/paired%3Adone-1%3Acompleted/actions',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'run' }),
},
),
);
expect(completed.status).toBe(409);
const wrongMethod = await handler(
new Request(
'http://localhost/api/inbox/paired%3Apaired-1%3Areview_ready/actions',
{
method: 'GET',
},
),
);
expect(wrongMethod.status).toBe(405);
const notWired = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
getPairedTaskById: () => pairedTask,
});
const noQueue = await notWired(
new Request(
'http://localhost/api/inbox/paired%3Apaired-1%3Areview_ready/actions',
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'run' }),
},
),
);
expect(noQueue.status).toBe(503);
});
it('injects room messages and queues room work from the web dashboard', async () => {
const messages: NewMessage[] = [];
const metadata: Array<{

View File

@@ -6,6 +6,7 @@ import {
deleteTask,
getAllOpenPairedTasks,
getAllTasks,
getPairedTaskById,
getTaskById,
hasMessage,
storeChatMetadata,
@@ -13,6 +14,8 @@ import {
updateTask,
} from './db.js';
import { logger } from './logger.js';
import { schedulePairedFollowUpIntent } from './message-runtime-follow-up.js';
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import {
readStatusSnapshots,
type StatusSnapshot,
@@ -31,6 +34,19 @@ import {
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
type PairedFollowUpTask = Pick<
PairedTask,
'id' | 'status' | 'round_trip_count' | 'updated_at'
>;
type WebPairedFollowUpScheduler = (args: {
chatJid: string;
runId: string;
task: PairedFollowUpTask;
intentKind: ScheduledPairedFollowUpIntentKind;
enqueue: () => void;
}) => boolean;
export interface WebDashboardHandlerOptions {
staticDir?: string;
statusMaxAgeMs?: number;
@@ -43,6 +59,8 @@ export interface WebDashboardHandlerOptions {
) => void;
deleteTask?: (id: string) => void;
getPairedTasks?: () => PairedTask[];
getPairedTaskById?: (id: string) => PairedTask | undefined;
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
getRoomBindings?: () => Record<string, RegisteredGroup>;
storeChatMetadata?: (
chatJid: string,
@@ -134,6 +152,7 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
}
type TaskAction = 'pause' | 'resume' | 'cancel';
type InboxAction = 'run';
function parseTaskActionPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/);
@@ -145,6 +164,16 @@ function parseTaskActionPath(pathname: string): string | null {
}
}
function parseInboxActionPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/inbox\/([^/]+)\/actions$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parseRoomMessagePath(pathname: string): string | null {
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/);
if (!match) return null;
@@ -155,10 +184,41 @@ function parseRoomMessagePath(pathname: string): string | null {
}
}
function parsePairedInboxTarget(
inboxId: string,
): { taskId: string; status: PairedTask['status'] } | null {
if (!inboxId.startsWith('paired:')) return null;
const rest = inboxId.slice('paired:'.length);
const separatorIndex = rest.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === rest.length - 1) return null;
const status = rest.slice(separatorIndex + 1);
if (!isPairedTaskStatus(status)) return null;
return {
taskId: rest.slice(0, separatorIndex),
status,
};
}
function isTaskAction(value: unknown): value is TaskAction {
return value === 'pause' || value === 'resume' || value === 'cancel';
}
function isInboxAction(value: unknown): value is InboxAction {
return value === 'run';
}
function isPairedTaskStatus(value: unknown): value is PairedTask['status'] {
return (
value === 'active' ||
value === 'review_ready' ||
value === 'in_review' ||
value === 'merge_ready' ||
value === 'completed' ||
value === 'arbiter_requested' ||
value === 'in_arbitration'
);
}
async function readTaskAction(request: Request): Promise<TaskAction | null> {
try {
const body = (await request.json()) as { action?: unknown };
@@ -168,6 +228,15 @@ async function readTaskAction(request: Request): Promise<TaskAction | null> {
}
}
async function readInboxAction(request: Request): Promise<InboxAction | null> {
try {
const body = (await request.json()) as { action?: unknown };
return isInboxAction(body.action) ? body.action : null;
} catch {
return null;
}
}
function sanitizeRoomMessageRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -204,6 +273,25 @@ function makeWebMessageIdFromRequest(requestId: string | null): string {
return requestId ? `web-${requestId}` : makeWebMessageId();
}
function makeWebRunId(prefix: string): string {
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function pairedFollowUpIntentForStatus(
status: PairedTask['status'],
): ScheduledPairedFollowUpIntentKind | null {
if (status === 'review_ready' || status === 'in_review') {
return 'reviewer-turn';
}
if (status === 'merge_ready') {
return 'finalize-owner-turn';
}
if (status === 'arbiter_requested' || status === 'in_arbitration') {
return 'arbiter-turn';
}
return null;
}
export function createWebDashboardHandler(
opts: WebDashboardHandlerOptions = {},
): (request: Request) => Response | Promise<Response> {
@@ -213,6 +301,9 @@ export function createWebDashboardHandler(
const mutateTask = opts.updateTask ?? updateTask;
const removeTask = opts.deleteTask ?? deleteTask;
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById;
const schedulePairedFollowUp =
opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent;
const loadRoomBindings = opts.getRoomBindings;
const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata;
const writeMessage = opts.storeMessage ?? storeMessage;
@@ -236,6 +327,7 @@ export function createWebDashboardHandler(
return async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const actionTaskId = parseTaskActionPath(url.pathname);
const actionInboxId = parseInboxActionPath(url.pathname);
const messageRoomJid = parseRoomMessagePath(url.pathname);
if (actionTaskId) {
@@ -277,6 +369,74 @@ export function createWebDashboardHandler(
});
}
if (actionInboxId) {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
const action = await readInboxAction(request);
if (!action) {
return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 });
}
const pairedTarget = parsePairedInboxTarget(actionInboxId);
if (!pairedTarget) {
return jsonResponse(
{ error: 'Unsupported inbox action target' },
{ status: 400 },
);
}
if (!enqueueMessageCheck) {
return jsonResponse(
{ error: 'Paired follow-up queue is not configured' },
{ status: 503 },
);
}
const task = loadPairedTaskById(pairedTarget.taskId);
if (!task) {
return jsonResponse(
{ error: 'Paired task not found' },
{ status: 404 },
);
}
if (task.status !== pairedTarget.status) {
return jsonResponse(
{
error: 'Inbox item is stale',
status: task.status,
},
{ status: 409 },
);
}
const intentKind = pairedFollowUpIntentForStatus(task.status);
if (!intentKind) {
return jsonResponse(
{ error: 'Paired task is not actionable' },
{ status: 409 },
);
}
const queued = schedulePairedFollowUp({
chatJid: task.chat_jid,
runId: makeWebRunId('inbox'),
task,
intentKind,
enqueue: () => enqueueMessageCheck(task.chat_jid, task.group_folder),
});
return jsonResponse({
ok: true,
id: actionInboxId,
taskId: task.id,
intentKind,
queued,
});
}
if (messageRoomJid) {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });