feat(dashboard): run paired inbox actions (#34)
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type DashboardInboxAction,
|
||||||
type DashboardTaskAction,
|
type DashboardTaskAction,
|
||||||
type DashboardOverview,
|
type DashboardOverview,
|
||||||
type DashboardTask,
|
type DashboardTask,
|
||||||
type StatusSnapshot,
|
type StatusSnapshot,
|
||||||
fetchDashboardData,
|
fetchDashboardData,
|
||||||
|
runInboxAction,
|
||||||
runScheduledTaskAction,
|
runScheduledTaskAction,
|
||||||
sendRoomMessage,
|
sendRoomMessage,
|
||||||
} from './api';
|
} from './api';
|
||||||
@@ -36,6 +38,7 @@ type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled';
|
|||||||
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
||||||
type TaskResultTone = 'ok' | 'fail' | 'none';
|
type TaskResultTone = 'ok' | 'fail' | 'none';
|
||||||
type TaskActionKey = `${string}:${DashboardTaskAction}`;
|
type TaskActionKey = `${string}:${DashboardTaskAction}`;
|
||||||
|
type InboxActionKey = `${string}:${DashboardInboxAction}`;
|
||||||
type InboxFilter = 'all' | InboxItem['kind'];
|
type InboxFilter = 'all' | InboxItem['kind'];
|
||||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
type HealthLevel = 'ok' | 'stale' | 'down';
|
||||||
|
|
||||||
@@ -292,6 +295,30 @@ function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
|
||||||
|
if (item.source !== 'paired-task') return [];
|
||||||
|
if (
|
||||||
|
item.kind === 'reviewer-request' ||
|
||||||
|
item.kind === 'approval' ||
|
||||||
|
item.kind === 'arbiter-request'
|
||||||
|
) {
|
||||||
|
return ['run'];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function inboxActionLabel(
|
||||||
|
item: InboxItem,
|
||||||
|
action: DashboardInboxAction,
|
||||||
|
t: Messages,
|
||||||
|
): string {
|
||||||
|
if (action !== 'run') return action;
|
||||||
|
if (item.kind === 'reviewer-request') return t.inbox.actions.runReview;
|
||||||
|
if (item.kind === 'approval') return t.inbox.actions.finalize;
|
||||||
|
if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter;
|
||||||
|
return t.inbox.actions.run;
|
||||||
|
}
|
||||||
|
|
||||||
const INBOX_FILTERS: InboxFilter[] = [
|
const INBOX_FILTERS: InboxFilter[] = [
|
||||||
'all',
|
'all',
|
||||||
'ci-failure',
|
'ci-failure',
|
||||||
@@ -600,14 +627,18 @@ function InboxPanel({
|
|||||||
overview,
|
overview,
|
||||||
tasks,
|
tasks,
|
||||||
locale,
|
locale,
|
||||||
|
onInboxAction,
|
||||||
onTaskAction,
|
onTaskAction,
|
||||||
|
inboxActionKey,
|
||||||
taskActionKey,
|
taskActionKey,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
overview: DashboardOverview;
|
overview: DashboardOverview;
|
||||||
tasks: DashboardTask[];
|
tasks: DashboardTask[];
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void;
|
||||||
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
|
||||||
|
inboxActionKey: InboxActionKey | null;
|
||||||
taskActionKey: TaskActionKey | null;
|
taskActionKey: TaskActionKey | null;
|
||||||
t: Messages;
|
t: Messages;
|
||||||
}) {
|
}) {
|
||||||
@@ -690,6 +721,7 @@ function InboxPanel({
|
|||||||
const linkedTaskActions = linkedTask
|
const linkedTaskActions = linkedTask
|
||||||
? taskActionsFor(linkedTask)
|
? taskActionsFor(linkedTask)
|
||||||
: [];
|
: [];
|
||||||
|
const inboxActions = inboxActionsFor(item);
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`inbox-card inbox-${item.severity}`}
|
className={`inbox-card inbox-${item.severity}`}
|
||||||
@@ -754,6 +786,27 @@ function InboxPanel({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{inboxActions.length > 0 ? (
|
||||||
|
<div className="task-actions inbox-actions">
|
||||||
|
{inboxActions.map((action) => {
|
||||||
|
const actionKey: InboxActionKey = `${item.id}:${action}`;
|
||||||
|
const busy = inboxActionKey === actionKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`task-action task-action-${action}`}
|
||||||
|
disabled={busy}
|
||||||
|
key={action}
|
||||||
|
onClick={() => onInboxAction(item, action)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? t.inbox.actions.busy
|
||||||
|
: inboxActionLabel(item, action, t)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1429,6 +1482,9 @@ function App() {
|
|||||||
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
||||||
const t = messages[locale];
|
const t = messages[locale];
|
||||||
|
|
||||||
@@ -1478,6 +1534,22 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleInboxAction(
|
||||||
|
item: InboxItem,
|
||||||
|
action: DashboardInboxAction,
|
||||||
|
) {
|
||||||
|
const actionKey: InboxActionKey = `${item.id}:${action}`;
|
||||||
|
setInboxActionKey(actionKey);
|
||||||
|
try {
|
||||||
|
await runInboxAction(item.id, action);
|
||||||
|
await refresh(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setInboxActionKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRoomMessage(
|
async function handleRoomMessage(
|
||||||
roomJid: string,
|
roomJid: string,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -1591,7 +1663,11 @@ function App() {
|
|||||||
<span>{t.panels.inboxQueue}</span>
|
<span>{t.panels.inboxQueue}</span>
|
||||||
</div>
|
</div>
|
||||||
<InboxPanel
|
<InboxPanel
|
||||||
|
inboxActionKey={inboxActionKey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
onInboxAction={(item, action) =>
|
||||||
|
void handleInboxAction(item, action)
|
||||||
|
}
|
||||||
onTaskAction={(task, action) =>
|
onTaskAction={(task, action) =>
|
||||||
void handleTaskAction(task, action)
|
void handleTaskAction(task, action)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export interface DashboardTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
||||||
|
export type DashboardInboxAction = 'run';
|
||||||
|
|
||||||
async function fetchJson<T>(path: string): Promise<T> {
|
async function fetchJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
@@ -162,6 +163,21 @@ export async function runScheduledTaskAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runInboxAction(
|
||||||
|
inboxId: string,
|
||||||
|
action: DashboardInboxAction,
|
||||||
|
): Promise<{
|
||||||
|
ok: true;
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
intentKind: string;
|
||||||
|
queued: boolean;
|
||||||
|
}> {
|
||||||
|
return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, {
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendRoomMessage(
|
export async function sendRoomMessage(
|
||||||
roomJid: string,
|
roomJid: string,
|
||||||
text: string,
|
text: string,
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ export interface Messages {
|
|||||||
warn: string;
|
warn: string;
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
actions: {
|
||||||
|
run: string;
|
||||||
|
busy: string;
|
||||||
|
runReview: string;
|
||||||
|
finalize: string;
|
||||||
|
runArbiter: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
rooms: {
|
rooms: {
|
||||||
empty: string;
|
empty: string;
|
||||||
@@ -324,6 +331,13 @@ export const messages = {
|
|||||||
warn: '주의',
|
warn: '주의',
|
||||||
error: '위험',
|
error: '위험',
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
run: 'Run',
|
||||||
|
busy: 'Running...',
|
||||||
|
runReview: 'Run review',
|
||||||
|
finalize: 'Finalize',
|
||||||
|
runArbiter: 'Run arbiter',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
empty: '룸 없음.',
|
empty: '룸 없음.',
|
||||||
@@ -528,6 +542,13 @@ export const messages = {
|
|||||||
warn: 'Warn',
|
warn: 'Warn',
|
||||||
error: 'Risk',
|
error: 'Risk',
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
run: 'Run',
|
||||||
|
busy: 'Running...',
|
||||||
|
runReview: 'Run review',
|
||||||
|
finalize: 'Finalize',
|
||||||
|
runArbiter: 'Run arbiter',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
empty: 'No rooms yet.',
|
empty: 'No rooms yet.',
|
||||||
@@ -732,6 +753,13 @@ export const messages = {
|
|||||||
warn: '关注',
|
warn: '关注',
|
||||||
error: '风险',
|
error: '风险',
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
run: '运行',
|
||||||
|
busy: '运行中...',
|
||||||
|
runReview: '运行评审',
|
||||||
|
finalize: '完成',
|
||||||
|
runArbiter: '运行仲裁',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
empty: '暂无房间。',
|
empty: '暂无房间。',
|
||||||
@@ -936,6 +964,13 @@ export const messages = {
|
|||||||
warn: '注意',
|
warn: '注意',
|
||||||
error: '危険',
|
error: '危険',
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
run: '実行',
|
||||||
|
busy: '実行中...',
|
||||||
|
runReview: 'レビュー実行',
|
||||||
|
finalize: 'Finalize',
|
||||||
|
runArbiter: '仲裁実行',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
empty: 'ルームなし。',
|
empty: 'ルームなし。',
|
||||||
|
|||||||
@@ -349,6 +349,212 @@ describe('web dashboard server handler', () => {
|
|||||||
expect(wrongMethod.status).toBe(405);
|
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 () => {
|
it('injects room messages and queues room work from the web dashboard', async () => {
|
||||||
const messages: NewMessage[] = [];
|
const messages: NewMessage[] = [];
|
||||||
const metadata: Array<{
|
const metadata: Array<{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
getAllOpenPairedTasks,
|
getAllOpenPairedTasks,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
|
getPairedTaskById,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
hasMessage,
|
hasMessage,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
updateTask,
|
updateTask,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { schedulePairedFollowUpIntent } from './message-runtime-follow-up.js';
|
||||||
|
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||||
import {
|
import {
|
||||||
readStatusSnapshots,
|
readStatusSnapshots,
|
||||||
type StatusSnapshot,
|
type StatusSnapshot,
|
||||||
@@ -31,6 +34,19 @@ import {
|
|||||||
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
|
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
|
||||||
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
|
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 {
|
export interface WebDashboardHandlerOptions {
|
||||||
staticDir?: string;
|
staticDir?: string;
|
||||||
statusMaxAgeMs?: number;
|
statusMaxAgeMs?: number;
|
||||||
@@ -43,6 +59,8 @@ export interface WebDashboardHandlerOptions {
|
|||||||
) => void;
|
) => void;
|
||||||
deleteTask?: (id: string) => void;
|
deleteTask?: (id: string) => void;
|
||||||
getPairedTasks?: () => PairedTask[];
|
getPairedTasks?: () => PairedTask[];
|
||||||
|
getPairedTaskById?: (id: string) => PairedTask | undefined;
|
||||||
|
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
|
||||||
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
||||||
storeChatMetadata?: (
|
storeChatMetadata?: (
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
@@ -134,6 +152,7 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskAction = 'pause' | 'resume' | 'cancel';
|
type TaskAction = 'pause' | 'resume' | 'cancel';
|
||||||
|
type InboxAction = 'run';
|
||||||
|
|
||||||
function parseTaskActionPath(pathname: string): string | null {
|
function parseTaskActionPath(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/);
|
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 {
|
function parseRoomMessagePath(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/);
|
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/);
|
||||||
if (!match) return null;
|
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 {
|
function isTaskAction(value: unknown): value is TaskAction {
|
||||||
return value === 'pause' || value === 'resume' || value === 'cancel';
|
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> {
|
async function readTaskAction(request: Request): Promise<TaskAction | null> {
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as { action?: unknown };
|
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 {
|
function sanitizeRoomMessageRequestId(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -204,6 +273,25 @@ function makeWebMessageIdFromRequest(requestId: string | null): string {
|
|||||||
return requestId ? `web-${requestId}` : makeWebMessageId();
|
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(
|
export function createWebDashboardHandler(
|
||||||
opts: WebDashboardHandlerOptions = {},
|
opts: WebDashboardHandlerOptions = {},
|
||||||
): (request: Request) => Response | Promise<Response> {
|
): (request: Request) => Response | Promise<Response> {
|
||||||
@@ -213,6 +301,9 @@ export function createWebDashboardHandler(
|
|||||||
const mutateTask = opts.updateTask ?? updateTask;
|
const mutateTask = opts.updateTask ?? updateTask;
|
||||||
const removeTask = opts.deleteTask ?? deleteTask;
|
const removeTask = opts.deleteTask ?? deleteTask;
|
||||||
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
|
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
|
||||||
|
const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById;
|
||||||
|
const schedulePairedFollowUp =
|
||||||
|
opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent;
|
||||||
const loadRoomBindings = opts.getRoomBindings;
|
const loadRoomBindings = opts.getRoomBindings;
|
||||||
const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata;
|
const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata;
|
||||||
const writeMessage = opts.storeMessage ?? storeMessage;
|
const writeMessage = opts.storeMessage ?? storeMessage;
|
||||||
@@ -236,6 +327,7 @@ export function createWebDashboardHandler(
|
|||||||
return async (request: Request): Promise<Response> => {
|
return async (request: Request): Promise<Response> => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const actionTaskId = parseTaskActionPath(url.pathname);
|
const actionTaskId = parseTaskActionPath(url.pathname);
|
||||||
|
const actionInboxId = parseInboxActionPath(url.pathname);
|
||||||
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
||||||
|
|
||||||
if (actionTaskId) {
|
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 (messageRoomJid) {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||||
|
|||||||
Reference in New Issue
Block a user