feat: add codex tasks overview
This commit is contained in:
+318
-1
@@ -7,6 +7,8 @@ const defaultTraceLimit = 80;
|
||||
const maxTraceLimit = 500;
|
||||
const defaultOutputLimit = 20;
|
||||
const defaultTextPreviewChars = 12_000;
|
||||
const defaultTasksLimit = 20;
|
||||
const maxTasksLimit = 100;
|
||||
|
||||
interface CodexTaskOptions {
|
||||
trace: boolean;
|
||||
@@ -51,6 +53,42 @@ interface CompactTaskMutationResponseOptions {
|
||||
fullPrompt?: boolean;
|
||||
}
|
||||
|
||||
interface CodexTasksOptions {
|
||||
queueId: string | undefined;
|
||||
limit: number;
|
||||
unreadOnly: boolean;
|
||||
}
|
||||
|
||||
interface CodexTasksEntry {
|
||||
taskId: string;
|
||||
queueId: string | null;
|
||||
status: string | null;
|
||||
currentAttempt: number | null;
|
||||
updatedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
readAt: string | null;
|
||||
unread: boolean;
|
||||
unreadTerminal: boolean;
|
||||
lastAssistantMessage: Record<string, unknown> | null;
|
||||
commands: {
|
||||
show: string;
|
||||
trace: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CodexTasksSection {
|
||||
count: number;
|
||||
returned: number;
|
||||
truncated: boolean;
|
||||
items: CodexTasksEntry[];
|
||||
}
|
||||
|
||||
interface CodexTasksDegraded {
|
||||
summaryFetchFailedTaskIds: string[];
|
||||
summaryFetchErrorCount: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
type CodexRequestInit = { method?: string; body?: unknown };
|
||||
type CodexResponseFetcher = (path: string, init?: CodexRequestInit) => unknown;
|
||||
type AsyncCodexResponseFetcher = (path: string, init?: CodexRequestInit) => Promise<unknown>;
|
||||
@@ -528,6 +566,14 @@ function parseOutputOptions(args: string[]): CodexOutputOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function parseTasksOptions(args: string[]): CodexTasksOptions {
|
||||
return {
|
||||
queueId: optionValue(args, ["--queue", "--queue-id"]),
|
||||
limit: positiveIntegerOption(args, ["--limit"], defaultTasksLimit, maxTasksLimit),
|
||||
unreadOnly: hasFlag(args, "--unread-only"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJudgeOptions(args: string[]): CodexJudgeOptions {
|
||||
const rawAttempt = optionValue(args, ["--attempt", "--attempt-id", "--attemptIndex"]) ?? positionalArgs(args)[0];
|
||||
let attempt: number | null = null;
|
||||
@@ -639,6 +685,272 @@ export function codexJudgeQuery(taskId: string, optionArgs: string[], fetcher: C
|
||||
return codexTaskJudge(taskId, parseJudgeOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
function isTerminalTaskStatus(status: unknown): boolean {
|
||||
return status === "succeeded" || status === "failed" || status === "canceled";
|
||||
}
|
||||
|
||||
function isActiveTaskStatus(status: unknown): boolean {
|
||||
return status === "running" || status === "judging" || status === "retry_wait" || status === "queued";
|
||||
}
|
||||
|
||||
function taskStatusRank(status: unknown): number {
|
||||
if (status === "running") return 0;
|
||||
if (status === "judging") return 1;
|
||||
if (status === "retry_wait") return 2;
|
||||
if (status === "queued") return 3;
|
||||
if (status === "succeeded") return 9;
|
||||
if (status === "failed") return 10;
|
||||
if (status === "canceled") return 11;
|
||||
return 99;
|
||||
}
|
||||
|
||||
function taskTimelineMs(task: Record<string, unknown>): number {
|
||||
const value = asString(task.finishedAt ?? task.updatedAt ?? task.createdAt);
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function taskOverviewCandidateKey(task: Record<string, unknown>): string {
|
||||
return asString(task.id);
|
||||
}
|
||||
|
||||
function taskUnreadTerminal(task: Record<string, unknown>): boolean {
|
||||
const directUnread = task.terminalUnread ?? task.unreadTerminal ?? task.unread;
|
||||
if (typeof directUnread === "boolean") return directUnread;
|
||||
const status = asString(task.status);
|
||||
const readAt = task.readAt;
|
||||
return isTerminalTaskStatus(status) && (readAt === null || readAt === undefined || readAt === "");
|
||||
}
|
||||
|
||||
function taskWatchEntry(task: Record<string, unknown>, summary: Record<string, unknown> | null): CodexTasksEntry {
|
||||
const taskId = asString(task.id);
|
||||
const summaryCommands = summary === null ? null : asRecord(summary.commands);
|
||||
const summaryLastAssistant = summary?.lastAssistantMessage ?? task.lastAssistantMessage;
|
||||
const showCommand = typeof summary?.cliHint === "string" && summary.cliHint.length > 0
|
||||
? summary.cliHint
|
||||
: `bun scripts/cli.ts codex task ${taskId}`;
|
||||
const traceCommand = typeof summary?.traceHint === "string" && summary.traceHint.length > 0
|
||||
? summary.traceHint
|
||||
: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`;
|
||||
return {
|
||||
taskId,
|
||||
queueId: asString(task.queueId) || null,
|
||||
status: asString(task.status) || null,
|
||||
currentAttempt: typeof task.currentAttempt === "number" && Number.isFinite(task.currentAttempt) ? task.currentAttempt : null,
|
||||
updatedAt: asString(task.updatedAt) || null,
|
||||
finishedAt: asString(task.finishedAt) || null,
|
||||
readAt: asString(task.readAt) || null,
|
||||
unread: taskUnreadTerminal(task),
|
||||
unreadTerminal: taskUnreadTerminal(task),
|
||||
lastAssistantMessage: summaryLastAssistant === undefined || summaryLastAssistant === null ? null : compactLastAssistant(summaryLastAssistant, false),
|
||||
commands: {
|
||||
show: typeof summaryCommands?.show === "string" && summaryCommands.show.length > 0 ? summaryCommands.show : showCommand,
|
||||
trace: typeof summaryCommands?.trace === "string" && summaryCommands.trace.length > 0 ? summaryCommands.trace : traceCommand,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskWatchSection(tasks: Record<string, unknown>[], summaries: Map<string, Record<string, unknown>>, limit: number): CodexTasksSection {
|
||||
const visibleTasks = tasks.slice(0, limit);
|
||||
const items = visibleTasks.map((task) => taskWatchEntry(task, summaries.get(taskOverviewCandidateKey(task)) ?? null));
|
||||
return {
|
||||
count: tasks.length,
|
||||
returned: items.length,
|
||||
truncated: tasks.length > limit,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function collectTaskWatchDegraded(summaryErrors: Array<{ taskId: string; message: string }>): CodexTasksDegraded | null {
|
||||
if (summaryErrors.length === 0) return null;
|
||||
return {
|
||||
summaryFetchFailedTaskIds: summaryErrors.map((error) => error.taskId),
|
||||
summaryFetchErrorCount: summaryErrors.length,
|
||||
reason: "task summary fetch failed for one or more entries; unread state still comes from task-level overview data",
|
||||
};
|
||||
}
|
||||
|
||||
function sortRunningWatchTasks(tasks: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
return [...tasks]
|
||||
.filter((task) => isActiveTaskStatus(asString(task.status)))
|
||||
.sort((left, right) => {
|
||||
const rankDelta = taskStatusRank(asString(left.status)) - taskStatusRank(asString(right.status));
|
||||
if (rankDelta !== 0) return rankDelta;
|
||||
const timeDelta = taskTimelineMs(right) - taskTimelineMs(left);
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return asString(left.id).localeCompare(asString(right.id));
|
||||
});
|
||||
}
|
||||
|
||||
function sortCompletedWatchTasks(tasks: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
return [...tasks]
|
||||
.filter((task) => isTerminalTaskStatus(asString(task.status)))
|
||||
.sort((left, right) => {
|
||||
const timeDelta = taskTimelineMs(right) - taskTimelineMs(left);
|
||||
if (timeDelta !== 0) return timeDelta;
|
||||
return asString(left.id).localeCompare(asString(right.id));
|
||||
});
|
||||
}
|
||||
|
||||
function fetchTaskSummaries(taskIds: string[], fetcher: CodexResponseFetcher): { summaries: Map<string, Record<string, unknown>>; degraded: CodexTasksDegraded | null } {
|
||||
const summaries = new Map<string, Record<string, unknown>>();
|
||||
const errors: Array<{ taskId: string; message: string }> = [];
|
||||
for (const taskId of taskIds) {
|
||||
try {
|
||||
const response = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: 1 })}`)));
|
||||
const summary = asRecord(response.body.summary) ?? {};
|
||||
summaries.set(taskId, summary);
|
||||
} catch (error) {
|
||||
errors.push({ taskId, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
return { summaries, degraded: collectTaskWatchDegraded(errors) };
|
||||
}
|
||||
|
||||
async function fetchTaskSummariesAsync(taskIds: string[], fetcher: AsyncCodexResponseFetcher): Promise<{ summaries: Map<string, Record<string, unknown>>; degraded: CodexTasksDegraded | null }> {
|
||||
const summaries = new Map<string, Record<string, unknown>>();
|
||||
const errors: Array<{ taskId: string; message: string }> = [];
|
||||
await Promise.all(taskIds.map(async (taskId) => {
|
||||
try {
|
||||
const response = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: 1 })}`)));
|
||||
const summary = asRecord(response.body.summary) ?? {};
|
||||
summaries.set(taskId, summary);
|
||||
} catch (error) {
|
||||
errors.push({ taskId, message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}));
|
||||
return { summaries, degraded: collectTaskWatchDegraded(errors) };
|
||||
}
|
||||
|
||||
type CodexTasksTaskPage = {
|
||||
queue: Record<string, unknown> | null;
|
||||
pagination: Record<string, unknown>;
|
||||
tasks: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
function tasksListQueryString(options: CodexTasksOptions): string {
|
||||
return queryString({
|
||||
limit: 200,
|
||||
priorityLimit: 200,
|
||||
queueId: options.queueId,
|
||||
includeActive: 1,
|
||||
selected: 0,
|
||||
compact: 1,
|
||||
stats: 0,
|
||||
skipTrace: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function loadCodexTasks(taskArgs: CodexTasksOptions, fetcher: CodexResponseFetcher): { upstream: { ok: unknown; status: unknown }; page: CodexTasksTaskPage } {
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
const response = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/overview${tasksListQueryString(taskArgs)}`)));
|
||||
const pageTasks = asArray(response.body.tasks).map((task) => asRecord(task)).filter((task): task is Record<string, unknown> => task !== null);
|
||||
for (const task of pageTasks) {
|
||||
const taskId = taskOverviewCandidateKey(task);
|
||||
if (taskId.length === 0) continue;
|
||||
const existing = byId.get(taskId);
|
||||
if (existing === undefined || taskTimelineMs(task) >= taskTimelineMs(existing)) byId.set(taskId, task);
|
||||
}
|
||||
const tasks = Array.from(byId.values()).sort((left, right) => {
|
||||
const leftTime = taskTimelineMs(left);
|
||||
const rightTime = taskTimelineMs(right);
|
||||
if (leftTime !== rightTime) return rightTime - leftTime;
|
||||
return asString(left.id).localeCompare(asString(right.id));
|
||||
});
|
||||
return { upstream: response.upstream, page: { queue: asRecord(response.body.queue), pagination: asRecord(response.body.pagination) ?? {}, tasks } };
|
||||
}
|
||||
|
||||
function codexTasksOverviewResult(
|
||||
taskPage: CodexTasksTaskPage,
|
||||
upstream: { ok: unknown; status: unknown },
|
||||
options: CodexTasksOptions,
|
||||
summaries: Map<string, Record<string, unknown>>,
|
||||
degraded: CodexTasksDegraded | null,
|
||||
): Record<string, unknown> {
|
||||
const allTasks = options.queueId === undefined ? taskPage.tasks : taskPage.tasks.filter((task) => asString(task.queueId) === options.queueId);
|
||||
const runningTasks = sortRunningWatchTasks(allTasks);
|
||||
const unreadCompletedTasks = sortCompletedWatchTasks(allTasks).filter((task) => taskUnreadTerminal(task));
|
||||
const recentCompletedTasks = options.unreadOnly ? [] : sortCompletedWatchTasks(allTasks);
|
||||
const runningSection = buildTaskWatchSection(runningTasks, summaries, options.limit);
|
||||
const unreadSection = buildTaskWatchSection(unreadCompletedTasks, summaries, options.limit);
|
||||
const recentSection = buildTaskWatchSection(recentCompletedTasks, summaries, options.limit);
|
||||
const pagination = taskPage.pagination;
|
||||
return {
|
||||
upstream,
|
||||
overview: {
|
||||
filters: {
|
||||
queueId: options.queueId ?? null,
|
||||
limit: options.limit,
|
||||
unreadOnly: options.unreadOnly,
|
||||
},
|
||||
source: {
|
||||
endpoint: "/api/tasks/overview",
|
||||
queueId: options.queueId ?? null,
|
||||
limit: asNumber(pagination.limit, 0) || null,
|
||||
returned: asNumber(pagination.returned, 0) || null,
|
||||
total: asNumber(pagination.total, 0) || null,
|
||||
hasMore: asBoolean(pagination.hasMore),
|
||||
nextBeforeId: asString(pagination.nextBeforeId) || null,
|
||||
includeActive: asBoolean(pagination.includeActive),
|
||||
},
|
||||
queue: taskPage.queue,
|
||||
counts: {
|
||||
scanned: allTasks.length,
|
||||
running: runningSection.count,
|
||||
completedUnread: unreadSection.count,
|
||||
recentCompleted: recentSection.count,
|
||||
},
|
||||
degraded,
|
||||
commands: {
|
||||
refresh: `bun scripts/cli.ts codex tasks${options.queueId === undefined ? "" : ` --queue ${options.queueId}`}${options.unreadOnly ? " --unread-only" : ""}${options.limit === defaultTasksLimit ? "" : ` --limit ${options.limit}`}`,
|
||||
},
|
||||
running: runningSection,
|
||||
completedUnread: unreadSection,
|
||||
recentCompleted: recentSection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function visibleTaskIdsForOverview(tasks: Record<string, unknown>[], options: CodexTasksOptions): string[] {
|
||||
return Array.from(new Set([
|
||||
...sortRunningWatchTasks(tasks).slice(0, options.limit),
|
||||
...sortCompletedWatchTasks(tasks).filter((task) => taskUnreadTerminal(task)).slice(0, options.limit),
|
||||
...sortCompletedWatchTasks(tasks).slice(0, options.limit),
|
||||
].map((task) => taskOverviewCandidateKey(task))))
|
||||
.filter((taskId) => taskId.length > 0);
|
||||
}
|
||||
|
||||
function codexTasksQuery(taskArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
const options = parseTasksOptions(taskArgs);
|
||||
const { upstream, page } = loadCodexTasks(options, fetcher);
|
||||
const sectionTaskIds = visibleTaskIdsForOverview(page.tasks, options);
|
||||
const { summaries, degraded } = fetchTaskSummaries(sectionTaskIds, fetcher);
|
||||
return codexTasksOverviewResult(page, upstream, options, summaries, degraded);
|
||||
}
|
||||
|
||||
async function codexTasksQueryAsync(taskArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const options = parseTasksOptions(taskArgs);
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
const response = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/overview${tasksListQueryString(options)}`)));
|
||||
const pageTasks = asArray(response.body.tasks).map((task) => asRecord(task)).filter((task): task is Record<string, unknown> => task !== null);
|
||||
for (const task of pageTasks) {
|
||||
const taskId = taskOverviewCandidateKey(task);
|
||||
if (taskId.length === 0) continue;
|
||||
const existing = byId.get(taskId);
|
||||
if (existing === undefined || taskTimelineMs(task) >= taskTimelineMs(existing)) byId.set(taskId, task);
|
||||
}
|
||||
const tasks = Array.from(byId.values()).sort((left, right) => {
|
||||
const leftTime = taskTimelineMs(left);
|
||||
const rightTime = taskTimelineMs(right);
|
||||
if (leftTime !== rightTime) return rightTime - leftTime;
|
||||
return asString(left.id).localeCompare(asString(right.id));
|
||||
});
|
||||
const page: CodexTasksTaskPage = { queue: asRecord(response.body.queue), pagination: asRecord(response.body.pagination) ?? {}, tasks };
|
||||
const visibleTaskIds = visibleTaskIdsForOverview(page.tasks, options);
|
||||
const { summaries, degraded } = await fetchTaskSummariesAsync(visibleTaskIds, fetcher);
|
||||
return codexTasksOverviewResult(page, response.upstream, options, summaries, degraded);
|
||||
}
|
||||
|
||||
async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const summaryPath = codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`);
|
||||
const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath));
|
||||
@@ -695,6 +1007,8 @@ export async function codexJudgeQueryAsync(taskId: string, optionArgs: string[],
|
||||
return codexTaskJudgeAsync(taskId, parseJudgeOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
export { codexTasksQueryAsync };
|
||||
|
||||
function requireQueueId(args: string[], command: string): string {
|
||||
const index = args.indexOf("--queue");
|
||||
const raw = index === -1 ? args[0] : args[index + 1];
|
||||
@@ -947,6 +1261,9 @@ export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]
|
||||
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
|
||||
return codexTaskQuery(taskId, args.slice(2));
|
||||
}
|
||||
if (action === "tasks" || action === "overview") {
|
||||
return codexTasksQuery(args.slice(1));
|
||||
}
|
||||
if (action === "output") {
|
||||
const taskId = requireTaskId(taskIdArg, "codex output");
|
||||
return codexOutputQuery(taskId, args.slice(2));
|
||||
@@ -973,5 +1290,5 @@ export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]
|
||||
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
|
||||
return codexInterruptTask(taskId);
|
||||
}
|
||||
throw new Error("codex command must be one of: submit, enqueue, task, summary, show, output, judge, queues, queue list, queue create, queue merge, move, interrupt, cancel");
|
||||
throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, output, judge, queues, queue list, queue create, queue merge, move, interrupt, cancel");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user