feat: 实现 Queue Q1 API 和 CLI 骨架
This commit is contained in:
+117
-1
@@ -1,4 +1,4 @@
|
||||
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateRunInput, FailureKind, JsonRecord, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionRecord, SessionRef, TerminalStatus } from "../common/types.js";
|
||||
import type { BackendProfile, BackendTurnResult, CommandRecord, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, FailureKind, JsonRecord, QueueCommanderSnapshot, QueueReadCursorRecord, QueueStats, QueueTaskListResult, QueueTaskRecord, QueueTaskState, RunEvent, RunnerJobRecord, RunnerRecord, RunRecord, SessionRecord, SessionRef, TerminalStatus } from "../common/types.js";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
import { newId, nowIso, stableHash } from "../common/validation.js";
|
||||
import { redactJson } from "../common/redaction.js";
|
||||
@@ -39,10 +39,25 @@ export interface AgentRunStore {
|
||||
cancelRun(runId: string, reason?: string): MaybePromise<RunRecord>;
|
||||
cancelCommand(commandId: string, reason?: string): MaybePromise<CommandRecord>;
|
||||
getSession(sessionId: string): MaybePromise<SessionRecord | null>;
|
||||
createQueueTask(input: CreateQueueTaskInput): MaybePromise<QueueTaskRecord>;
|
||||
listQueueTasks(input: ListQueueTasksInput): MaybePromise<QueueTaskListResult>;
|
||||
getQueueTask(taskId: string): MaybePromise<QueueTaskRecord>;
|
||||
cancelQueueTask(taskId: string, reason?: string): MaybePromise<QueueTaskRecord>;
|
||||
markQueueTaskRead(taskId: string, readerId: string): MaybePromise<QueueReadCursorRecord>;
|
||||
queueStats(queue?: string): MaybePromise<QueueStats>;
|
||||
queueCommander(queue?: string): MaybePromise<QueueCommanderSnapshot>;
|
||||
backends(): MaybePromise<JsonRecord[]>;
|
||||
close?(): MaybePromise<void>;
|
||||
}
|
||||
|
||||
export interface ListQueueTasksInput {
|
||||
queue?: string;
|
||||
state?: QueueTaskState;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
updatedAfter?: number;
|
||||
}
|
||||
|
||||
export interface SaveRunnerJobInput {
|
||||
runId: string;
|
||||
commandId: string;
|
||||
@@ -77,6 +92,9 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
private readonly runners = new Map<string, RunnerRecord>();
|
||||
private readonly sessions = new Map<string, SessionRecord>();
|
||||
private readonly runnerJobs = new Map<string, RunnerJobRecord>();
|
||||
private readonly queueTasks = new Map<string, QueueTaskRecord>();
|
||||
private readonly queueReadCursors = new Map<string, QueueReadCursorRecord>();
|
||||
private queueVersion = 0;
|
||||
|
||||
health(): StoreHealth {
|
||||
return { adapter: "memory-self-test", ready: true, reachable: true, migrationReady: true, migrationId: "memory-self-test", failureKind: null, message: null, credentialValuesPrinted: false };
|
||||
@@ -247,6 +265,64 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
return this.sessions.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
createQueueTask(input: CreateQueueTaskInput): QueueTaskRecord {
|
||||
const payloadHash = stableHash(input.payload);
|
||||
if (input.idempotencyKey) {
|
||||
const existing = Array.from(this.queueTasks.values()).find((task) => task.tenantId === input.tenantId && task.projectId === input.projectId && task.idempotencyKey === input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (existing.payloadHash !== payloadHash) throw new AgentRunError("schema-invalid", "queue task idempotency key reused with different payload", { httpStatus: 409 });
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const at = nowIso();
|
||||
const task: QueueTaskRecord = { ...input, id: newId("qt"), state: "pending", version: this.nextQueueVersion(), payloadHash, latestAttempt: null, sessionPath: null, createdAt: at, updatedAt: at, cancelledAt: null, cancelReason: null };
|
||||
this.queueTasks.set(task.id, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
listQueueTasks(input: ListQueueTasksInput): QueueTaskListResult {
|
||||
const startVersion = parseQueueCursor(input.cursor) ?? input.updatedAfter ?? 0;
|
||||
const items = Array.from(this.queueTasks.values())
|
||||
.filter((task) => task.version > startVersion)
|
||||
.filter((task) => !input.queue || task.queue === input.queue)
|
||||
.filter((task) => !input.state || task.state === input.state)
|
||||
.sort(queueTaskSort)
|
||||
.slice(0, clampQueueLimit(input.limit));
|
||||
return { items, count: items.length, cursor: items.length > 0 ? String(items[items.length - 1]?.version ?? startVersion) : null };
|
||||
}
|
||||
|
||||
getQueueTask(taskId: string): QueueTaskRecord {
|
||||
const task = this.queueTasks.get(taskId);
|
||||
if (!task) throw new AgentRunError("schema-invalid", `queue task ${taskId} was not found`, { httpStatus: 404 });
|
||||
return task;
|
||||
}
|
||||
|
||||
cancelQueueTask(taskId: string, reason = "cancel requested"): QueueTaskRecord {
|
||||
const task = this.getQueueTask(taskId);
|
||||
if (isTerminalQueueTaskState(task.state)) return task;
|
||||
const at = nowIso();
|
||||
const next: QueueTaskRecord = { ...task, state: "cancelled", version: this.nextQueueVersion(), updatedAt: at, cancelledAt: at, cancelReason: reason };
|
||||
this.queueTasks.set(taskId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
markQueueTaskRead(taskId: string, readerId: string): QueueReadCursorRecord {
|
||||
const task = this.getQueueTask(taskId);
|
||||
const record: QueueReadCursorRecord = { taskId, readerId, taskVersion: task.version, readAt: nowIso() };
|
||||
this.queueReadCursors.set(queueReadKey(taskId, readerId), record);
|
||||
return record;
|
||||
}
|
||||
|
||||
queueStats(queue?: string): QueueStats {
|
||||
return buildQueueStats(Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue), queue ?? null);
|
||||
}
|
||||
|
||||
queueCommander(queue?: string): QueueCommanderSnapshot {
|
||||
const items = Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue).sort(queueTaskSort).slice(0, 20);
|
||||
const generatedAt = nowIso();
|
||||
return { queue: queue ?? null, stats: buildQueueStats(Array.from(this.queueTasks.values()).filter((task) => !queue || task.queue === queue), queue ?? null, generatedAt), items, generatedAt };
|
||||
}
|
||||
|
||||
backends(): JsonRecord[] {
|
||||
return backendCapabilities();
|
||||
}
|
||||
@@ -258,6 +334,11 @@ export class MemoryAgentRunStore implements AgentRunStore {
|
||||
return next;
|
||||
}
|
||||
|
||||
private nextQueueVersion(): number {
|
||||
this.queueVersion += 1;
|
||||
return this.queueVersion;
|
||||
}
|
||||
|
||||
private resolveSessionForRun(input: CreateRunInput, at: string): SessionRef | null {
|
||||
if (!input.sessionRef) return null;
|
||||
const existing = this.sessions.get(input.sessionRef.sessionId);
|
||||
@@ -334,6 +415,10 @@ export function isTerminalCommandState(state: CommandRecord["state"]): boolean {
|
||||
return state === "completed" || state === "failed" || state === "cancelled";
|
||||
}
|
||||
|
||||
export function isTerminalQueueTaskState(state: QueueTaskState): boolean {
|
||||
return state === "completed" || state === "failed" || state === "blocked" || state === "cancelled";
|
||||
}
|
||||
|
||||
export function sessionRefFromRecord(record: SessionRecord, fallback: SessionRef): SessionRef {
|
||||
return {
|
||||
sessionId: record.sessionId,
|
||||
@@ -369,3 +454,34 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
|
||||
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function queueTaskSort(a: QueueTaskRecord, b: QueueTaskRecord): number {
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
export function buildQueueStats(tasks: QueueTaskRecord[], queue: string | null, generatedAt = nowIso()): QueueStats {
|
||||
const byState: Record<string, number> = {};
|
||||
const byLane: Record<string, number> = {};
|
||||
let maxVersion = 0;
|
||||
for (const task of tasks) {
|
||||
byState[task.state] = (byState[task.state] ?? 0) + 1;
|
||||
byLane[task.lane] = (byLane[task.lane] ?? 0) + 1;
|
||||
maxVersion = Math.max(maxVersion, task.version);
|
||||
}
|
||||
return { queue, total: tasks.length, byState, byLane, maxVersion, generatedAt };
|
||||
}
|
||||
|
||||
export function clampQueueLimit(limit: number): number {
|
||||
return Math.max(1, Math.min(Number.isFinite(limit) ? Math.trunc(limit) : 50, 100));
|
||||
}
|
||||
|
||||
export function parseQueueCursor(cursor: string | undefined): number | null {
|
||||
if (!cursor) return null;
|
||||
const value = Number(cursor);
|
||||
return Number.isInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function queueReadKey(taskId: string, readerId: string): string {
|
||||
return `${taskId}:${readerId}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user