Add dashboard inbox decline and dismiss actions
Add dashboard inbox decline and dismiss actions
This commit is contained in:
@@ -390,15 +390,15 @@ function readTaskForm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
|
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
|
||||||
if (item.source !== 'paired-task') return [];
|
|
||||||
if (
|
if (
|
||||||
item.kind === 'reviewer-request' ||
|
item.source === 'paired-task' &&
|
||||||
|
(item.kind === 'reviewer-request' ||
|
||||||
item.kind === 'approval' ||
|
item.kind === 'approval' ||
|
||||||
item.kind === 'arbiter-request'
|
item.kind === 'arbiter-request')
|
||||||
) {
|
) {
|
||||||
return ['run'];
|
return ['run', 'decline', 'dismiss'];
|
||||||
}
|
}
|
||||||
return [];
|
return ['dismiss'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function inboxActionLabel(
|
function inboxActionLabel(
|
||||||
@@ -406,7 +406,8 @@ function inboxActionLabel(
|
|||||||
action: DashboardInboxAction,
|
action: DashboardInboxAction,
|
||||||
t: Messages,
|
t: Messages,
|
||||||
): string {
|
): string {
|
||||||
if (action !== 'run') return action;
|
if (action === 'dismiss') return t.inbox.actions.dismiss;
|
||||||
|
if (action === 'decline') return t.inbox.actions.decline;
|
||||||
if (item.kind === 'reviewer-request') return t.inbox.actions.runReview;
|
if (item.kind === 'reviewer-request') return t.inbox.actions.runReview;
|
||||||
if (item.kind === 'approval') return t.inbox.actions.finalize;
|
if (item.kind === 'approval') return t.inbox.actions.finalize;
|
||||||
if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter;
|
if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter;
|
||||||
@@ -1785,10 +1786,21 @@ function App() {
|
|||||||
item: InboxItem,
|
item: InboxItem,
|
||||||
action: DashboardInboxAction,
|
action: DashboardInboxAction,
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
action === 'decline' &&
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
!window.confirm(t.inbox.actions.confirmDecline)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const actionKey: InboxActionKey = `${item.id}:${action}`;
|
const actionKey: InboxActionKey = `${item.id}:${action}`;
|
||||||
setInboxActionKey(actionKey);
|
setInboxActionKey(actionKey);
|
||||||
try {
|
try {
|
||||||
await runInboxAction(item.id, action);
|
await runInboxAction(item.id, action, {
|
||||||
|
lastOccurredAt: item.lastOccurredAt,
|
||||||
|
requestId: makeClientRequestId(),
|
||||||
|
});
|
||||||
await refresh(false);
|
await refresh(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export interface DashboardTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
||||||
export type DashboardInboxAction = 'run';
|
export type DashboardInboxAction = 'run' | 'decline' | 'dismiss';
|
||||||
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
|
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
|
||||||
export type DashboardTaskContextMode = 'group' | 'isolated';
|
export type DashboardTaskContextMode = 'group' | 'isolated';
|
||||||
|
|
||||||
@@ -214,15 +214,21 @@ export async function updateScheduledTask(
|
|||||||
export async function runInboxAction(
|
export async function runInboxAction(
|
||||||
inboxId: string,
|
inboxId: string,
|
||||||
action: DashboardInboxAction,
|
action: DashboardInboxAction,
|
||||||
|
options: { requestId?: string; lastOccurredAt?: string } = {},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
ok: true;
|
ok: true;
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId?: string;
|
||||||
intentKind: string;
|
intentKind?: string;
|
||||||
queued: boolean;
|
status?: string;
|
||||||
|
queued?: boolean;
|
||||||
|
dismissed?: boolean;
|
||||||
|
duplicate?: boolean;
|
||||||
}> {
|
}> {
|
||||||
return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, {
|
return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, {
|
||||||
action,
|
action,
|
||||||
|
lastOccurredAt: options.lastOccurredAt,
|
||||||
|
requestId: options.requestId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export interface Messages {
|
|||||||
runReview: string;
|
runReview: string;
|
||||||
finalize: string;
|
finalize: string;
|
||||||
runArbiter: string;
|
runArbiter: string;
|
||||||
|
decline: string;
|
||||||
|
dismiss: string;
|
||||||
|
confirmDecline: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -358,6 +361,9 @@ export const messages = {
|
|||||||
runReview: 'Run review',
|
runReview: 'Run review',
|
||||||
finalize: 'Finalize',
|
finalize: 'Finalize',
|
||||||
runArbiter: 'Run arbiter',
|
runArbiter: 'Run arbiter',
|
||||||
|
decline: 'Decline',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
confirmDecline: 'Decline this item?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -590,6 +596,9 @@ export const messages = {
|
|||||||
runReview: 'Run review',
|
runReview: 'Run review',
|
||||||
finalize: 'Finalize',
|
finalize: 'Finalize',
|
||||||
runArbiter: 'Run arbiter',
|
runArbiter: 'Run arbiter',
|
||||||
|
decline: 'Decline',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
confirmDecline: 'Decline this item?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -822,6 +831,9 @@ export const messages = {
|
|||||||
runReview: '运行评审',
|
runReview: '运行评审',
|
||||||
finalize: '完成',
|
finalize: '完成',
|
||||||
runArbiter: '运行仲裁',
|
runArbiter: '运行仲裁',
|
||||||
|
decline: '拒绝',
|
||||||
|
dismiss: '忽略',
|
||||||
|
confirmDecline: '确认拒绝此项?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
@@ -1054,6 +1066,9 @@ export const messages = {
|
|||||||
runReview: 'レビュー実行',
|
runReview: 'レビュー実行',
|
||||||
finalize: 'Finalize',
|
finalize: 'Finalize',
|
||||||
runArbiter: '仲裁実行',
|
runArbiter: '仲裁実行',
|
||||||
|
decline: '拒否',
|
||||||
|
dismiss: '非表示',
|
||||||
|
confirmDecline: 'この項目を拒否しますか?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: {
|
||||||
|
|||||||
@@ -238,6 +238,79 @@ describe('web dashboard server handler', () => {
|
|||||||
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dismisses inbox items until they change', async () => {
|
||||||
|
const snapshots: StatusSnapshot[] = [
|
||||||
|
{
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
agentType: 'codex',
|
||||||
|
assistantName: 'Codex',
|
||||||
|
updatedAt: '2026-04-26T05:00:00.000Z',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
jid: 'dc:ops',
|
||||||
|
name: '#ops',
|
||||||
|
folder: 'ops',
|
||||||
|
agentType: 'codex',
|
||||||
|
status: 'waiting',
|
||||||
|
elapsedMs: 2500,
|
||||||
|
pendingMessages: true,
|
||||||
|
pendingTasks: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const handler = createWebDashboardHandler({
|
||||||
|
readStatusSnapshots: () => snapshots,
|
||||||
|
getTasks: () => [],
|
||||||
|
getPairedTasks: () => [],
|
||||||
|
now: () => '2026-04-26T05:10:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstOverview = await handler(
|
||||||
|
new Request('http://localhost/api/overview'),
|
||||||
|
);
|
||||||
|
expect(firstOverview.status).toBe(200);
|
||||||
|
const firstBody = (await firstOverview.json()) as {
|
||||||
|
inbox: Array<{ id: string; lastOccurredAt: string }>;
|
||||||
|
};
|
||||||
|
expect(firstBody.inbox).toHaveLength(1);
|
||||||
|
|
||||||
|
const dismiss = await handler(
|
||||||
|
new Request(
|
||||||
|
`http://localhost/api/inbox/${encodeURIComponent(firstBody.inbox[0]!.id)}/actions`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'dismiss',
|
||||||
|
lastOccurredAt: firstBody.inbox[0]!.lastOccurredAt,
|
||||||
|
requestId: 'dismiss-1',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(dismiss.status).toBe(200);
|
||||||
|
|
||||||
|
const dismissedOverview = await handler(
|
||||||
|
new Request('http://localhost/api/overview'),
|
||||||
|
);
|
||||||
|
expect(dismissedOverview.status).toBe(200);
|
||||||
|
await expect(dismissedOverview.json()).resolves.toMatchObject({
|
||||||
|
inbox: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
snapshots[0] = {
|
||||||
|
...snapshots[0]!,
|
||||||
|
updatedAt: '2026-04-26T05:01:00.000Z',
|
||||||
|
};
|
||||||
|
const changedOverview = await handler(
|
||||||
|
new Request('http://localhost/api/overview'),
|
||||||
|
);
|
||||||
|
expect(changedOverview.status).toBe(200);
|
||||||
|
const changedBody = (await changedOverview.json()) as { inbox: unknown[] };
|
||||||
|
expect(changedBody.inbox).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('mutates scheduled tasks through explicit action endpoints', async () => {
|
it('mutates scheduled tasks through explicit action endpoints', async () => {
|
||||||
let task: ScheduledTask | undefined = makeTask({
|
let task: ScheduledTask | undefined = makeTask({
|
||||||
id: 'ci-watch-1',
|
id: 'ci-watch-1',
|
||||||
@@ -744,6 +817,114 @@ describe('web dashboard server handler', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('declines paired inbox actions back to the owner queue', async () => {
|
||||||
|
const pairedTasks = new Map<string, PairedTask>([
|
||||||
|
[
|
||||||
|
'merge-1',
|
||||||
|
makePairedTask({
|
||||||
|
id: 'merge-1',
|
||||||
|
status: 'merge_ready',
|
||||||
|
updated_at: '2026-04-26T05:02:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const messages: NewMessage[] = [];
|
||||||
|
const metadata: Array<{
|
||||||
|
chatJid: string;
|
||||||
|
timestamp: string;
|
||||||
|
channel?: string;
|
||||||
|
isGroup?: boolean;
|
||||||
|
}> = [];
|
||||||
|
const queued: Array<{ chatJid: string; groupFolder: string }> = [];
|
||||||
|
const handler = createWebDashboardHandler({
|
||||||
|
readStatusSnapshots: () => [],
|
||||||
|
getTasks: () => [],
|
||||||
|
getPairedTasks: () => [...pairedTasks.values()],
|
||||||
|
getPairedTaskById: (id) => pairedTasks.get(id),
|
||||||
|
updatePairedTaskIfUnchanged: (id, expectedUpdatedAt, updates) => {
|
||||||
|
const task = pairedTasks.get(id);
|
||||||
|
if (!task || task.updated_at !== expectedUpdatedAt) return false;
|
||||||
|
pairedTasks.set(id, { ...task, ...updates });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
schedulePairedFollowUp: () => {
|
||||||
|
throw new Error('schedulePairedFollowUp should not be called');
|
||||||
|
},
|
||||||
|
storeChatMetadata: (chatJid, timestamp, _name, channel, isGroup) => {
|
||||||
|
metadata.push({ chatJid, timestamp, channel, isGroup });
|
||||||
|
},
|
||||||
|
storeMessage: (message) => {
|
||||||
|
messages.push(message);
|
||||||
|
},
|
||||||
|
hasMessage: (chatJid, id) =>
|
||||||
|
messages.some(
|
||||||
|
(message) => message.chat_jid === chatJid && message.id === id,
|
||||||
|
),
|
||||||
|
enqueueMessageCheck: (chatJid, groupFolder) => {
|
||||||
|
queued.push({ chatJid, groupFolder });
|
||||||
|
},
|
||||||
|
now: () => '2026-04-26T05:15:00.000Z',
|
||||||
|
});
|
||||||
|
const decline = () =>
|
||||||
|
handler(
|
||||||
|
new Request(
|
||||||
|
'http://localhost/api/inbox/paired%3Amerge-1%3Amerge_ready/actions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'decline',
|
||||||
|
requestId: 'decline-merge-1',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await decline();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
await expect(response.json()).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
taskId: 'merge-1',
|
||||||
|
status: 'active',
|
||||||
|
queued: true,
|
||||||
|
});
|
||||||
|
expect(pairedTasks.get('merge-1')).toMatchObject({
|
||||||
|
status: 'active',
|
||||||
|
updated_at: '2026-04-26T05:15:00.000Z',
|
||||||
|
});
|
||||||
|
expect(metadata).toEqual([
|
||||||
|
{
|
||||||
|
chatJid: 'dc:general',
|
||||||
|
timestamp: '2026-04-26T05:15:00.000Z',
|
||||||
|
channel: 'web-dashboard',
|
||||||
|
isGroup: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0]).toMatchObject({
|
||||||
|
id: 'web-inbox-decline-merge-1',
|
||||||
|
chat_jid: 'dc:general',
|
||||||
|
sender: 'web-dashboard',
|
||||||
|
sender_name: 'Web Dashboard',
|
||||||
|
content: 'Dashboard declined finalization. Continue with the owner turn.',
|
||||||
|
timestamp: '2026-04-26T05:15:00.000Z',
|
||||||
|
is_from_me: false,
|
||||||
|
is_bot_message: false,
|
||||||
|
message_source_kind: 'ipc_injected_human',
|
||||||
|
});
|
||||||
|
expect(queued).toEqual([{ chatJid: 'dc:general', groupFolder: 'general' }]);
|
||||||
|
|
||||||
|
const duplicate = await decline();
|
||||||
|
expect(duplicate.status).toBe(200);
|
||||||
|
await expect(duplicate.json()).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
duplicate: true,
|
||||||
|
queued: false,
|
||||||
|
});
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(queued).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid paired inbox actions', async () => {
|
it('rejects invalid paired inbox actions', async () => {
|
||||||
const pairedTask = makePairedTask({
|
const pairedTask = makePairedTask({
|
||||||
id: 'paired-1',
|
id: 'paired-1',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
hasMessage,
|
hasMessage,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
|
updatePairedTaskIfUnchanged,
|
||||||
updateTask,
|
updateTask,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -89,6 +90,16 @@ export interface WebDashboardHandlerOptions {
|
|||||||
deleteTask?: (id: string) => void;
|
deleteTask?: (id: string) => void;
|
||||||
getPairedTasks?: () => PairedTask[];
|
getPairedTasks?: () => PairedTask[];
|
||||||
getPairedTaskById?: (id: string) => PairedTask | undefined;
|
getPairedTaskById?: (id: string) => PairedTask | undefined;
|
||||||
|
updatePairedTaskIfUnchanged?: (
|
||||||
|
id: string,
|
||||||
|
expectedUpdatedAt: string,
|
||||||
|
updates: Partial<
|
||||||
|
Pick<
|
||||||
|
PairedTask,
|
||||||
|
'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
) => boolean;
|
||||||
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
|
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
|
||||||
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
||||||
storeChatMetadata?: (
|
storeChatMetadata?: (
|
||||||
@@ -182,7 +193,13 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskAction = 'pause' | 'resume' | 'cancel';
|
type TaskAction = 'pause' | 'resume' | 'cancel';
|
||||||
type InboxAction = 'run';
|
type InboxAction = 'run' | 'decline' | 'dismiss';
|
||||||
|
|
||||||
|
interface InboxActionRequest {
|
||||||
|
action: InboxAction;
|
||||||
|
requestId: string | null;
|
||||||
|
lastOccurredAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseTaskPath(pathname: string): string | null {
|
function parseTaskPath(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
const match = pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
||||||
@@ -258,7 +275,7 @@ function isAgentType(value: unknown): value is AgentType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isInboxAction(value: unknown): value is InboxAction {
|
function isInboxAction(value: unknown): value is InboxAction {
|
||||||
return value === 'run';
|
return value === 'run' || value === 'decline' || value === 'dismiss';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPairedTaskStatus(value: unknown): value is PairedTask['status'] {
|
function isPairedTaskStatus(value: unknown): value is PairedTask['status'] {
|
||||||
@@ -282,10 +299,24 @@ async function readTaskAction(request: Request): Promise<TaskAction | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readInboxAction(request: Request): Promise<InboxAction | null> {
|
async function readInboxAction(
|
||||||
|
request: Request,
|
||||||
|
): Promise<InboxActionRequest | null> {
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as { action?: unknown };
|
const body = (await request.json()) as {
|
||||||
return isInboxAction(body.action) ? body.action : null;
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -433,6 +464,40 @@ function makeWebRunId(prefix: string): string {
|
|||||||
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
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(
|
function pairedFollowUpIntentForStatus(
|
||||||
status: PairedTask['status'],
|
status: PairedTask['status'],
|
||||||
): ScheduledPairedFollowUpIntentKind | null {
|
): ScheduledPairedFollowUpIntentKind | null {
|
||||||
@@ -459,6 +524,8 @@ export function createWebDashboardHandler(
|
|||||||
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 loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById;
|
||||||
|
const mutatePairedTaskIfUnchanged =
|
||||||
|
opts.updatePairedTaskIfUnchanged ?? updatePairedTaskIfUnchanged;
|
||||||
const schedulePairedFollowUp =
|
const schedulePairedFollowUp =
|
||||||
opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent;
|
opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent;
|
||||||
const loadRoomBindings = opts.getRoomBindings;
|
const loadRoomBindings = opts.getRoomBindings;
|
||||||
@@ -470,6 +537,7 @@ export function createWebDashboardHandler(
|
|||||||
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
||||||
const seenRoomMessageIds: string[] = [];
|
const seenRoomMessageIds: string[] = [];
|
||||||
const seenRoomMessageIdSet = new Set<string>();
|
const seenRoomMessageIdSet = new Set<string>();
|
||||||
|
const dismissedInboxKeys = new Set<string>();
|
||||||
|
|
||||||
function rememberRoomMessageId(id: string): boolean {
|
function rememberRoomMessageId(id: string): boolean {
|
||||||
if (seenRoomMessageIdSet.has(id)) return false;
|
if (seenRoomMessageIdSet.has(id)) return false;
|
||||||
@@ -482,6 +550,16 @@ export function createWebDashboardHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> => {
|
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);
|
||||||
@@ -533,11 +611,18 @@ export function createWebDashboardHandler(
|
|||||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = await readInboxAction(request);
|
const inboxRequest = await readInboxAction(request);
|
||||||
if (!action) {
|
if (!inboxRequest) {
|
||||||
return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 });
|
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);
|
const pairedTarget = parsePairedInboxTarget(actionInboxId);
|
||||||
if (!pairedTarget) {
|
if (!pairedTarget) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -561,6 +646,19 @@ export function createWebDashboardHandler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (task.status !== pairedTarget.status) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
@@ -571,6 +669,69 @@ export function createWebDashboardHandler(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
const intentKind = pairedFollowUpIntentForStatus(task.status);
|
||||||
if (!intentKind) {
|
if (!intentKind) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -825,14 +986,16 @@ export function createWebDashboardHandler(
|
|||||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
const snapshots = readSnapshots(statusMaxAgeMs);
|
||||||
const tasks = loadTasks();
|
const tasks = loadTasks();
|
||||||
const pairedTasks = loadPairedTasks();
|
const pairedTasks = loadPairedTasks();
|
||||||
return jsonResponse(
|
const overview = buildWebDashboardOverview({
|
||||||
buildWebDashboardOverview({
|
|
||||||
now: opts.now?.(),
|
now: opts.now?.(),
|
||||||
snapshots,
|
snapshots,
|
||||||
tasks,
|
tasks,
|
||||||
pairedTasks,
|
pairedTasks,
|
||||||
}),
|
});
|
||||||
);
|
return jsonResponse({
|
||||||
|
...overview,
|
||||||
|
inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/status-snapshots') {
|
if (url.pathname === '/api/status-snapshots') {
|
||||||
|
|||||||
Reference in New Issue
Block a user