Extract dashboard inbox action route
This commit is contained in:
@@ -26,7 +26,6 @@ import {
|
||||
} 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,
|
||||
@@ -38,6 +37,11 @@ import type {
|
||||
RegisteredGroup,
|
||||
ScheduledTask,
|
||||
} from './types.js';
|
||||
import {
|
||||
createInboxDismissTracker,
|
||||
handleInboxActionRoute,
|
||||
type InboxFollowUpScheduler,
|
||||
} from './web-dashboard-inbox-routes.js';
|
||||
import { handleOverviewRoute } from './web-dashboard-overview-routes.js';
|
||||
import {
|
||||
createRoomMessageIdCache,
|
||||
@@ -63,19 +67,6 @@ const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
|
||||
const UNIT_NOT_FOUND_PATTERN =
|
||||
/(Unit .* not found|Could not find the requested service|not-found)/i;
|
||||
|
||||
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 type { ServiceRestartRecord } from './web-dashboard-service-routes.js';
|
||||
|
||||
export interface WebDashboardHandlerOptions {
|
||||
@@ -132,7 +123,7 @@ export interface WebDashboardHandlerOptions {
|
||||
>
|
||||
>,
|
||||
) => boolean;
|
||||
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
|
||||
schedulePairedFollowUp?: InboxFollowUpScheduler;
|
||||
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
||||
storeChatMetadata?: (
|
||||
chatJid: string,
|
||||
@@ -244,131 +235,6 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
type InboxAction = 'run' | 'decline' | 'dismiss';
|
||||
|
||||
interface InboxActionRequest {
|
||||
action: InboxAction;
|
||||
requestId: string | null;
|
||||
lastOccurredAt: 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 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 isInboxAction(value: unknown): value is InboxAction {
|
||||
return value === 'run' || value === 'decline' || value === 'dismiss';
|
||||
}
|
||||
|
||||
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 readInboxAction(
|
||||
request: Request,
|
||||
): Promise<InboxActionRequest | null> {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
action?: unknown;
|
||||
requestId?: unknown;
|
||||
lastOccurredAt?: unknown;
|
||||
};
|
||||
if (!isInboxAction(body.action)) return null;
|
||||
return {
|
||||
action: body.action,
|
||||
requestId: sanitizeInboxActionRequestId(body.requestId),
|
||||
lastOccurredAt:
|
||||
typeof body.lastOccurredAt === 'string' && body.lastOccurredAt.trim()
|
||||
? body.lastOccurredAt.trim()
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function makeWebRunId(prefix: string): string {
|
||||
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function sanitizeInboxActionRequestId(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
|
||||
return safe || null;
|
||||
}
|
||||
|
||||
function makeInboxDismissKey(
|
||||
inboxId: string,
|
||||
lastOccurredAt: string | null,
|
||||
): string {
|
||||
return lastOccurredAt ? `${inboxId}\0${lastOccurredAt}` : inboxId;
|
||||
}
|
||||
|
||||
function makeWebInboxMessageId(requestId: string | null): string {
|
||||
return requestId
|
||||
? `web-inbox-${requestId}`
|
||||
: `web-inbox-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function declinedInboxMessage(status: PairedTask['status']): string {
|
||||
if (status === 'review_ready' || status === 'in_review') {
|
||||
return 'Dashboard declined review. Continue with the owner turn.';
|
||||
}
|
||||
if (status === 'merge_ready') {
|
||||
return 'Dashboard declined finalization. Continue with the owner turn.';
|
||||
}
|
||||
if (status === 'arbiter_requested' || status === 'in_arbitration') {
|
||||
return 'Dashboard declined arbiter escalation. Continue with the owner turn.';
|
||||
}
|
||||
return 'Dashboard declined this inbox item. Continue with the owner turn.';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isRoot(): boolean {
|
||||
return typeof process.getuid === 'function' && process.getuid() === 0;
|
||||
}
|
||||
@@ -477,23 +343,12 @@ export function createWebDashboardHandler(
|
||||
}
|
||||
|
||||
const rememberRoomMessageId = createRoomMessageIdCache();
|
||||
const dismissedInboxKeys = new Set<string>();
|
||||
const inboxDismissTracker = createInboxDismissTracker();
|
||||
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
||||
const activeServiceRestartTargets = new Set<string>();
|
||||
|
||||
function isInboxItemDismissed(item: {
|
||||
id: string;
|
||||
lastOccurredAt: string;
|
||||
}): boolean {
|
||||
return (
|
||||
dismissedInboxKeys.has(item.id) ||
|
||||
dismissedInboxKeys.has(makeInboxDismissKey(item.id, item.lastOccurredAt))
|
||||
);
|
||||
}
|
||||
|
||||
return async (request: Request): Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
const actionInboxId = parseInboxActionPath(url.pathname);
|
||||
|
||||
const scheduledTaskRoute = await handleScheduledTaskRoute({
|
||||
url,
|
||||
@@ -509,156 +364,21 @@ export function createWebDashboardHandler(
|
||||
});
|
||||
if (scheduledTaskRoute) return scheduledTaskRoute;
|
||||
|
||||
if (actionInboxId) {
|
||||
if (request.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
const inboxRequest = await readInboxAction(request);
|
||||
if (!inboxRequest) {
|
||||
return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (inboxRequest.action === 'dismiss') {
|
||||
dismissedInboxKeys.add(
|
||||
makeInboxDismissKey(actionInboxId, inboxRequest.lastOccurredAt),
|
||||
);
|
||||
return jsonResponse({ ok: true, id: actionInboxId, dismissed: true });
|
||||
}
|
||||
|
||||
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 (inboxRequest.action === 'decline') {
|
||||
const messageId = makeWebInboxMessageId(inboxRequest.requestId);
|
||||
if (inboxRequest.requestId && messageExists(task.chat_jid, messageId)) {
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
id: actionInboxId,
|
||||
taskId: task.id,
|
||||
queued: false,
|
||||
duplicate: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (task.status !== pairedTarget.status) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'Inbox item is stale',
|
||||
status: task.status,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (inboxRequest.action === 'decline') {
|
||||
const timestamp = opts.now?.() ?? new Date().toISOString();
|
||||
const messageId = makeWebInboxMessageId(inboxRequest.requestId);
|
||||
const updates: Partial<
|
||||
Pick<
|
||||
PairedTask,
|
||||
| 'status'
|
||||
| 'updated_at'
|
||||
| 'arbiter_requested_at'
|
||||
| 'completion_reason'
|
||||
>
|
||||
> = {
|
||||
status: 'active',
|
||||
updated_at: timestamp,
|
||||
};
|
||||
if (
|
||||
task.status === 'arbiter_requested' ||
|
||||
task.status === 'in_arbitration'
|
||||
) {
|
||||
updates.arbiter_requested_at = null;
|
||||
}
|
||||
|
||||
const updated = mutatePairedTaskIfUnchanged(
|
||||
task.id,
|
||||
task.updated_at,
|
||||
updates,
|
||||
);
|
||||
if (!updated) {
|
||||
return jsonResponse(
|
||||
{ error: 'Inbox item is stale', status: task.status },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
writeChatMetadata(
|
||||
task.chat_jid,
|
||||
timestamp,
|
||||
undefined,
|
||||
'web-dashboard',
|
||||
true,
|
||||
);
|
||||
writeMessage({
|
||||
id: messageId,
|
||||
chat_jid: task.chat_jid,
|
||||
sender: 'web-dashboard',
|
||||
sender_name: 'Web Dashboard',
|
||||
content: declinedInboxMessage(task.status),
|
||||
timestamp,
|
||||
is_from_me: false,
|
||||
is_bot_message: false,
|
||||
message_source_kind: 'ipc_injected_human',
|
||||
});
|
||||
enqueueMessageCheck(task.chat_jid, task.group_folder);
|
||||
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
id: actionInboxId,
|
||||
taskId: task.id,
|
||||
status: 'active',
|
||||
queued: true,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
const inboxActionRoute = await handleInboxActionRoute({
|
||||
url,
|
||||
request,
|
||||
jsonResponse,
|
||||
dismissTracker: inboxDismissTracker,
|
||||
enqueueMessageCheck,
|
||||
loadPairedTaskById,
|
||||
messageExists,
|
||||
mutatePairedTaskIfUnchanged,
|
||||
schedulePairedFollowUp,
|
||||
writeChatMetadata,
|
||||
writeMessage,
|
||||
now: opts.now,
|
||||
});
|
||||
if (inboxActionRoute) return inboxActionRoute;
|
||||
|
||||
const roomMessageRoute = await handleRoomMessageRoute({
|
||||
url,
|
||||
@@ -716,7 +436,7 @@ export function createWebDashboardHandler(
|
||||
const overviewRoute = handleOverviewRoute({
|
||||
url,
|
||||
jsonResponse,
|
||||
isInboxItemDismissed,
|
||||
isInboxItemDismissed: inboxDismissTracker.isDismissed,
|
||||
loadPairedTasks,
|
||||
loadTasks,
|
||||
readSnapshots,
|
||||
|
||||
Reference in New Issue
Block a user