Merge pull request #77 from phj1081/codex/refactor-dashboard-inbox-action-route
Extract dashboard inbox action route
This commit is contained in:
253
src/web-dashboard-inbox-routes.test.ts
Normal file
253
src/web-dashboard-inbox-routes.test.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { NewMessage, PairedTask } from './types.js';
|
||||||
|
import {
|
||||||
|
createInboxDismissTracker,
|
||||||
|
handleInboxActionRoute,
|
||||||
|
type InboxActionRouteDependencies,
|
||||||
|
} from './web-dashboard-inbox-routes.js';
|
||||||
|
|
||||||
|
function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
||||||
|
return {
|
||||||
|
id: 'paired-1',
|
||||||
|
chat_jid: 'dc:general',
|
||||||
|
group_folder: 'general',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude-reviewer',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: 'codex',
|
||||||
|
title: 'Dashboard PR',
|
||||||
|
source_ref: null,
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
owner_failure_count: 0,
|
||||||
|
owner_step_done_streak: 0,
|
||||||
|
finalize_step_done_count: 0,
|
||||||
|
task_done_then_user_reopen_count: 0,
|
||||||
|
empty_step_done_streak: 0,
|
||||||
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-26T04:00:00.000Z',
|
||||||
|
updated_at: '2026-04-26T04:30:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||||
|
return new Response(JSON.stringify(value), {
|
||||||
|
...init,
|
||||||
|
headers: { 'content-type': 'application/json', ...init?.headers },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inboxRequest(
|
||||||
|
inboxId: string,
|
||||||
|
body: unknown,
|
||||||
|
method = 'POST',
|
||||||
|
): Request {
|
||||||
|
return new Request(
|
||||||
|
`http://localhost/api/inbox/${encodeURIComponent(inboxId)}/actions`,
|
||||||
|
{
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: method === 'POST' ? JSON.stringify(body) : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDeps(
|
||||||
|
task: PairedTask | undefined,
|
||||||
|
overrides: Partial<InboxActionRouteDependencies> = {},
|
||||||
|
): InboxActionRouteDependencies {
|
||||||
|
return {
|
||||||
|
dismissTracker: createInboxDismissTracker(),
|
||||||
|
enqueueMessageCheck: () => undefined,
|
||||||
|
loadPairedTaskById: (id) => (id === task?.id ? task : undefined),
|
||||||
|
messageExists: () => false,
|
||||||
|
mutatePairedTaskIfUnchanged: () => true,
|
||||||
|
schedulePairedFollowUp: () => true,
|
||||||
|
writeChatMetadata: () => undefined,
|
||||||
|
writeMessage: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('web dashboard inbox action routes', () => {
|
||||||
|
it('dismisses inbox items and queues paired follow-up intents', async () => {
|
||||||
|
const dismissTracker = createInboxDismissTracker();
|
||||||
|
const task = makePairedTask({ id: 'merge-1', status: 'merge_ready' });
|
||||||
|
const scheduled: Array<{ taskId: string; intentKind: string }> = [];
|
||||||
|
const queued: Array<{ chatJid: string; groupFolder: string }> = [];
|
||||||
|
const deps = makeDeps(task, {
|
||||||
|
dismissTracker,
|
||||||
|
enqueueMessageCheck: (chatJid, groupFolder) => {
|
||||||
|
queued.push({ chatJid, groupFolder });
|
||||||
|
},
|
||||||
|
schedulePairedFollowUp: (args) => {
|
||||||
|
scheduled.push({
|
||||||
|
taskId: args.task.id,
|
||||||
|
intentKind: args.intentKind,
|
||||||
|
});
|
||||||
|
args.enqueue();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallThrough = await handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
jsonResponse,
|
||||||
|
request: new Request('http://localhost/api/overview'),
|
||||||
|
url: new URL('http://localhost/api/overview'),
|
||||||
|
});
|
||||||
|
expect(fallThrough).toBeNull();
|
||||||
|
|
||||||
|
const dismiss = await handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
jsonResponse,
|
||||||
|
request: inboxRequest('ci:task-1', {
|
||||||
|
action: 'dismiss',
|
||||||
|
lastOccurredAt: '2026-04-26T05:00:00.000Z',
|
||||||
|
}),
|
||||||
|
url: new URL('http://localhost/api/inbox/ci%3Atask-1/actions'),
|
||||||
|
});
|
||||||
|
expect(dismiss?.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
dismissTracker.isDismissed({
|
||||||
|
id: 'ci:task-1',
|
||||||
|
lastOccurredAt: '2026-04-26T05:00:00.000Z',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const run = await handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
jsonResponse,
|
||||||
|
request: inboxRequest('paired:merge-1:merge_ready', { action: 'run' }),
|
||||||
|
url: new URL(
|
||||||
|
'http://localhost/api/inbox/paired%3Amerge-1%3Amerge_ready/actions',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(run?.status).toBe(200);
|
||||||
|
await expect(run?.json()).resolves.toMatchObject({
|
||||||
|
intentKind: 'finalize-owner-turn',
|
||||||
|
ok: true,
|
||||||
|
queued: true,
|
||||||
|
taskId: 'merge-1',
|
||||||
|
});
|
||||||
|
expect(scheduled).toEqual([
|
||||||
|
{ taskId: 'merge-1', intentKind: 'finalize-owner-turn' },
|
||||||
|
]);
|
||||||
|
expect(queued).toEqual([{ chatJid: 'dc:general', groupFolder: 'general' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declines paired tasks and rejects invalid inbox actions', async () => {
|
||||||
|
const task = makePairedTask({
|
||||||
|
id: 'arbiter-1',
|
||||||
|
status: 'arbiter_requested',
|
||||||
|
arbiter_requested_at: '2026-04-26T05:00:00.000Z',
|
||||||
|
});
|
||||||
|
const messages: NewMessage[] = [];
|
||||||
|
const updates: unknown[] = [];
|
||||||
|
const deps = makeDeps(task, {
|
||||||
|
enqueueMessageCheck: () => undefined,
|
||||||
|
messageExists: (chatJid, id) =>
|
||||||
|
messages.some(
|
||||||
|
(message) => message.chat_jid === chatJid && message.id === id,
|
||||||
|
),
|
||||||
|
mutatePairedTaskIfUnchanged: (_id, _expectedUpdatedAt, update) => {
|
||||||
|
updates.push(update);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
writeMessage: (message) => {
|
||||||
|
messages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const decline = () =>
|
||||||
|
handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
jsonResponse,
|
||||||
|
now: () => '2026-04-26T05:15:00.000Z',
|
||||||
|
request: inboxRequest('paired:arbiter-1:arbiter_requested', {
|
||||||
|
action: 'decline',
|
||||||
|
requestId: 'decline 1',
|
||||||
|
}),
|
||||||
|
url: new URL(
|
||||||
|
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aarbiter_requested/actions',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await decline();
|
||||||
|
expect(response?.status).toBe(200);
|
||||||
|
await expect(response?.json()).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
queued: true,
|
||||||
|
status: 'active',
|
||||||
|
taskId: 'arbiter-1',
|
||||||
|
});
|
||||||
|
expect(updates).toHaveLength(1);
|
||||||
|
expect(updates[0]).toMatchObject({
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
status: 'active',
|
||||||
|
updated_at: '2026-04-26T05:15:00.000Z',
|
||||||
|
});
|
||||||
|
expect(messages[0]).toMatchObject({
|
||||||
|
id: 'web-inbox-decline-1',
|
||||||
|
content:
|
||||||
|
'Dashboard declined arbiter escalation. Continue with the owner turn.',
|
||||||
|
message_source_kind: 'ipc_injected_human',
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicate = await decline();
|
||||||
|
expect(duplicate?.status).toBe(200);
|
||||||
|
await expect(duplicate?.json()).resolves.toMatchObject({
|
||||||
|
duplicate: true,
|
||||||
|
queued: false,
|
||||||
|
});
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
|
||||||
|
const invalidBody = await handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
jsonResponse,
|
||||||
|
request: inboxRequest('paired:arbiter-1:active', { action: 'approve' }),
|
||||||
|
url: new URL(
|
||||||
|
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(invalidBody?.status).toBe(400);
|
||||||
|
|
||||||
|
const stale = await handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
jsonResponse,
|
||||||
|
request: inboxRequest('paired:arbiter-1:merge_ready', { action: 'run' }),
|
||||||
|
url: new URL(
|
||||||
|
'http://localhost/api/inbox/paired%3Aarbiter-1%3Amerge_ready/actions',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(stale?.status).toBe(409);
|
||||||
|
|
||||||
|
const notWired = await handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
enqueueMessageCheck: undefined,
|
||||||
|
jsonResponse,
|
||||||
|
request: inboxRequest('paired:arbiter-1:active', { action: 'run' }),
|
||||||
|
url: new URL(
|
||||||
|
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(notWired?.status).toBe(503);
|
||||||
|
|
||||||
|
const wrongMethod = await handleInboxActionRoute({
|
||||||
|
...deps,
|
||||||
|
jsonResponse,
|
||||||
|
request: inboxRequest('paired:arbiter-1:active', null, 'GET'),
|
||||||
|
url: new URL(
|
||||||
|
'http://localhost/api/inbox/paired%3Aarbiter-1%3Aactive/actions',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(wrongMethod?.status).toBe(405);
|
||||||
|
});
|
||||||
|
});
|
||||||
379
src/web-dashboard-inbox-routes.ts
Normal file
379
src/web-dashboard-inbox-routes.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||||
|
import type { NewMessage, PairedTask } from './types.js';
|
||||||
|
|
||||||
|
type JsonResponse = (
|
||||||
|
value: unknown,
|
||||||
|
init?: ResponseInit,
|
||||||
|
request?: Request,
|
||||||
|
) => Response;
|
||||||
|
|
||||||
|
type InboxAction = 'run' | 'decline' | 'dismiss';
|
||||||
|
|
||||||
|
interface InboxActionRequest {
|
||||||
|
action: InboxAction;
|
||||||
|
requestId: string | null;
|
||||||
|
lastOccurredAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboxFollowUpTask = Pick<
|
||||||
|
PairedTask,
|
||||||
|
'id' | 'status' | 'round_trip_count' | 'updated_at'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type InboxFollowUpScheduler = (args: {
|
||||||
|
chatJid: string;
|
||||||
|
runId: string;
|
||||||
|
task: InboxFollowUpTask;
|
||||||
|
intentKind: ScheduledPairedFollowUpIntentKind;
|
||||||
|
enqueue: () => void;
|
||||||
|
}) => boolean;
|
||||||
|
|
||||||
|
export interface InboxDismissTracker {
|
||||||
|
dismiss: (inboxId: string, lastOccurredAt: string | null) => void;
|
||||||
|
isDismissed: (item: { id: string; lastOccurredAt: string }) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboxActionRouteDependencies {
|
||||||
|
dismissTracker: InboxDismissTracker;
|
||||||
|
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
|
||||||
|
loadPairedTaskById: (id: string) => PairedTask | undefined;
|
||||||
|
messageExists: (chatJid: string, id: string) => boolean;
|
||||||
|
mutatePairedTaskIfUnchanged: (
|
||||||
|
id: string,
|
||||||
|
expectedUpdatedAt: string,
|
||||||
|
updates: Partial<
|
||||||
|
Pick<
|
||||||
|
PairedTask,
|
||||||
|
'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
) => boolean;
|
||||||
|
schedulePairedFollowUp: InboxFollowUpScheduler;
|
||||||
|
writeChatMetadata: (
|
||||||
|
chatJid: string,
|
||||||
|
timestamp: string,
|
||||||
|
name?: string,
|
||||||
|
channel?: string,
|
||||||
|
isGroup?: boolean,
|
||||||
|
) => void;
|
||||||
|
writeMessage: (message: NewMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InboxActionRouteContext extends InboxActionRouteDependencies {
|
||||||
|
jsonResponse: JsonResponse;
|
||||||
|
now?: () => string;
|
||||||
|
request: Request;
|
||||||
|
url: URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInboxDismissTracker(): InboxDismissTracker {
|
||||||
|
const dismissedInboxKeys = new Set<string>();
|
||||||
|
return {
|
||||||
|
dismiss: (inboxId, lastOccurredAt) => {
|
||||||
|
dismissedInboxKeys.add(makeInboxDismissKey(inboxId, lastOccurredAt));
|
||||||
|
},
|
||||||
|
isDismissed: (item) =>
|
||||||
|
dismissedInboxKeys.has(item.id) ||
|
||||||
|
dismissedInboxKeys.has(makeInboxDismissKey(item.id, item.lastOccurredAt)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleInboxActionRoute({
|
||||||
|
dismissTracker,
|
||||||
|
enqueueMessageCheck,
|
||||||
|
jsonResponse,
|
||||||
|
loadPairedTaskById,
|
||||||
|
messageExists,
|
||||||
|
mutatePairedTaskIfUnchanged,
|
||||||
|
now,
|
||||||
|
request,
|
||||||
|
schedulePairedFollowUp,
|
||||||
|
url,
|
||||||
|
writeChatMetadata,
|
||||||
|
writeMessage,
|
||||||
|
}: InboxActionRouteContext): Promise<Response | null> {
|
||||||
|
const actionInboxId = parseInboxActionPath(url.pathname);
|
||||||
|
if (!actionInboxId) return null;
|
||||||
|
|
||||||
|
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') {
|
||||||
|
dismissTracker.dismiss(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') {
|
||||||
|
return handleDeclineInboxAction({
|
||||||
|
actionInboxId,
|
||||||
|
enqueueMessageCheck,
|
||||||
|
jsonResponse,
|
||||||
|
messageId: makeWebInboxMessageId(inboxRequest.requestId),
|
||||||
|
mutatePairedTaskIfUnchanged,
|
||||||
|
now,
|
||||||
|
task,
|
||||||
|
writeChatMetadata,
|
||||||
|
writeMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeclineInboxAction({
|
||||||
|
actionInboxId,
|
||||||
|
enqueueMessageCheck,
|
||||||
|
jsonResponse,
|
||||||
|
messageId,
|
||||||
|
mutatePairedTaskIfUnchanged,
|
||||||
|
now,
|
||||||
|
task,
|
||||||
|
writeChatMetadata,
|
||||||
|
writeMessage,
|
||||||
|
}: {
|
||||||
|
actionInboxId: string;
|
||||||
|
enqueueMessageCheck: (chatJid: string, groupFolder: string) => void;
|
||||||
|
jsonResponse: JsonResponse;
|
||||||
|
messageId: string;
|
||||||
|
mutatePairedTaskIfUnchanged: InboxActionRouteDependencies['mutatePairedTaskIfUnchanged'];
|
||||||
|
now?: () => string;
|
||||||
|
task: PairedTask;
|
||||||
|
writeChatMetadata: InboxActionRouteDependencies['writeChatMetadata'];
|
||||||
|
writeMessage: InboxActionRouteDependencies['writeMessage'];
|
||||||
|
}): Response {
|
||||||
|
const timestamp = now?.() ?? new Date().toISOString();
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
} 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 { schedulePairedFollowUpIntent } from './message-runtime-follow-up.js';
|
||||||
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
|
||||||
import {
|
import {
|
||||||
readStatusSnapshots,
|
readStatusSnapshots,
|
||||||
type StatusSnapshot,
|
type StatusSnapshot,
|
||||||
@@ -38,6 +37,11 @@ import type {
|
|||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import {
|
||||||
|
createInboxDismissTracker,
|
||||||
|
handleInboxActionRoute,
|
||||||
|
type InboxFollowUpScheduler,
|
||||||
|
} from './web-dashboard-inbox-routes.js';
|
||||||
import { handleOverviewRoute } from './web-dashboard-overview-routes.js';
|
import { handleOverviewRoute } from './web-dashboard-overview-routes.js';
|
||||||
import {
|
import {
|
||||||
createRoomMessageIdCache,
|
createRoomMessageIdCache,
|
||||||
@@ -63,19 +67,6 @@ const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
|
|||||||
const UNIT_NOT_FOUND_PATTERN =
|
const UNIT_NOT_FOUND_PATTERN =
|
||||||
/(Unit .* not found|Could not find the requested service|not-found)/i;
|
/(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 type { ServiceRestartRecord } from './web-dashboard-service-routes.js';
|
||||||
|
|
||||||
export interface WebDashboardHandlerOptions {
|
export interface WebDashboardHandlerOptions {
|
||||||
@@ -132,7 +123,7 @@ export interface WebDashboardHandlerOptions {
|
|||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
|
schedulePairedFollowUp?: InboxFollowUpScheduler;
|
||||||
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
getRoomBindings?: () => Record<string, RegisteredGroup>;
|
||||||
storeChatMetadata?: (
|
storeChatMetadata?: (
|
||||||
chatJid: string,
|
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 {
|
function isRoot(): boolean {
|
||||||
return typeof process.getuid === 'function' && process.getuid() === 0;
|
return typeof process.getuid === 'function' && process.getuid() === 0;
|
||||||
}
|
}
|
||||||
@@ -477,23 +343,12 @@ export function createWebDashboardHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rememberRoomMessageId = createRoomMessageIdCache();
|
const rememberRoomMessageId = createRoomMessageIdCache();
|
||||||
const dismissedInboxKeys = new Set<string>();
|
const inboxDismissTracker = createInboxDismissTracker();
|
||||||
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
||||||
const activeServiceRestartTargets = new Set<string>();
|
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> => {
|
return async (request: Request): Promise<Response> => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const actionInboxId = parseInboxActionPath(url.pathname);
|
|
||||||
|
|
||||||
const scheduledTaskRoute = await handleScheduledTaskRoute({
|
const scheduledTaskRoute = await handleScheduledTaskRoute({
|
||||||
url,
|
url,
|
||||||
@@ -509,156 +364,21 @@ export function createWebDashboardHandler(
|
|||||||
});
|
});
|
||||||
if (scheduledTaskRoute) return scheduledTaskRoute;
|
if (scheduledTaskRoute) return scheduledTaskRoute;
|
||||||
|
|
||||||
if (actionInboxId) {
|
const inboxActionRoute = await handleInboxActionRoute({
|
||||||
if (request.method !== 'POST') {
|
url,
|
||||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
request,
|
||||||
}
|
jsonResponse,
|
||||||
|
dismissTracker: inboxDismissTracker,
|
||||||
const inboxRequest = await readInboxAction(request);
|
enqueueMessageCheck,
|
||||||
if (!inboxRequest) {
|
loadPairedTaskById,
|
||||||
return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 });
|
messageExists,
|
||||||
}
|
mutatePairedTaskIfUnchanged,
|
||||||
|
schedulePairedFollowUp,
|
||||||
if (inboxRequest.action === 'dismiss') {
|
writeChatMetadata,
|
||||||
dismissedInboxKeys.add(
|
writeMessage,
|
||||||
makeInboxDismissKey(actionInboxId, inboxRequest.lastOccurredAt),
|
now: opts.now,
|
||||||
);
|
});
|
||||||
return jsonResponse({ ok: true, id: actionInboxId, dismissed: true });
|
if (inboxActionRoute) return inboxActionRoute;
|
||||||
}
|
|
||||||
|
|
||||||
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 roomMessageRoute = await handleRoomMessageRoute({
|
const roomMessageRoute = await handleRoomMessageRoute({
|
||||||
url,
|
url,
|
||||||
@@ -716,7 +436,7 @@ export function createWebDashboardHandler(
|
|||||||
const overviewRoute = handleOverviewRoute({
|
const overviewRoute = handleOverviewRoute({
|
||||||
url,
|
url,
|
||||||
jsonResponse,
|
jsonResponse,
|
||||||
isInboxItemDismissed,
|
isInboxItemDismissed: inboxDismissTracker.isDismissed,
|
||||||
loadPairedTasks,
|
loadPairedTasks,
|
||||||
loadTasks,
|
loadTasks,
|
||||||
readSnapshots,
|
readSnapshots,
|
||||||
|
|||||||
Reference in New Issue
Block a user