Add Code Queue workdir presets
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -2483,6 +2483,25 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
.codex-submit-queue-select {
|
||||
grid-area: queue-select;
|
||||
}
|
||||
.codex-workdir-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.codex-workdir-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.72fr) auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-workdir-row input,
|
||||
.codex-workdir-row select {
|
||||
min-width: 0;
|
||||
}
|
||||
.codex-workdir-create-btn,
|
||||
.codex-workdir-delete-btn {
|
||||
min-height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codex-rename-queue-btn,
|
||||
.codex-merge-queue-btn,
|
||||
.codex-create-queue-btn {
|
||||
@@ -5574,6 +5593,9 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
"merge"
|
||||
"create";
|
||||
}
|
||||
.codex-workdir-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.codex-rename-queue-btn, .codex-merge-queue-btn, .codex-create-queue-btn { width: 100%; }
|
||||
.codex-session-title-toggle { min-height: 40px; padding: 9px 15px; font-size: 14px; }
|
||||
.codex-attempt-cycle-head { align-items: flex-start; }
|
||||
|
||||
@@ -1367,6 +1367,45 @@ function providerDefaultWorkdir(queue: any, providerId: string): string {
|
||||
return id === mainProvider ? String(queue?.defaultWorkdir || "/workspace") : String(queue?.remoteDefaultWorkdir || "/home/ubuntu");
|
||||
}
|
||||
|
||||
function normalizedWorkdirPath(value: any): string {
|
||||
return String(value || "").trim().replace(/\/+$/u, "") || "/";
|
||||
}
|
||||
|
||||
function workdirRecordMatches(record: any, providerId: string, executionMode: string): boolean {
|
||||
return String(record?.providerId || "") === String(providerId || "")
|
||||
&& String(record?.executionMode || "default") === String(executionMode || "default")
|
||||
&& String(record?.path || "").trim().length > 0;
|
||||
}
|
||||
|
||||
function workdirOptions(queue: any, records: any[], providerId: string, executionMode: string, cwd: string): any[] {
|
||||
const byPath = new Map<string, any>();
|
||||
const currentPath = normalizedWorkdirPath(cwd);
|
||||
const add = (pathValue: any, source: string, record: any = {}) => {
|
||||
const path = normalizedWorkdirPath(pathValue);
|
||||
if (path.length === 0 || byPath.has(path)) return;
|
||||
byPath.set(path, {
|
||||
providerId,
|
||||
executionMode,
|
||||
path,
|
||||
source,
|
||||
createdAt: record?.createdAt || "",
|
||||
updatedAt: record?.updatedAt || "",
|
||||
});
|
||||
};
|
||||
add(executionModeDefaultWorkdir(queue, executionMode, providerId), "default");
|
||||
for (const record of Array.isArray(records) ? records : []) {
|
||||
if (workdirRecordMatches(record, providerId, executionMode)) add(record.path, "saved", record);
|
||||
}
|
||||
add(cwd, "current");
|
||||
return Array.from(byPath.values()).sort((left, right) => {
|
||||
if (left.path === currentPath) return -1;
|
||||
if (right.path === currentPath) return 1;
|
||||
if (left.source === "default" && right.source !== "default") return -1;
|
||||
if (right.source === "default" && left.source !== "default") return 1;
|
||||
return left.path.localeCompare(right.path);
|
||||
});
|
||||
}
|
||||
|
||||
function taskStepCount(task: any): number | null {
|
||||
return canonicalTaskStepCount(task);
|
||||
}
|
||||
@@ -2042,6 +2081,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const [copiedTaskId, setCopiedTaskId] = useState("");
|
||||
const [markingReadTaskId, setMarkingReadTaskId] = useState("");
|
||||
const [markingAllRead, setMarkingAllRead] = useState(false);
|
||||
const [workdirData, setWorkdirData] = useState(null);
|
||||
const [workdirBusy, setWorkdirBusy] = useState(false);
|
||||
const [loadStats, setLoadStats] = useState(initialTasksData ? {
|
||||
phase: "complete",
|
||||
taskId: initialSelectedId,
|
||||
@@ -2104,6 +2145,9 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const providerOptions = codexProviderOptions(queue, providerId);
|
||||
const executionModeRows = codexExecutionModeOptions(queue, executionMode);
|
||||
const currentProviderDefaultWorkdir = executionModeDefaultWorkdir(queue, executionMode, providerId);
|
||||
const savedWorkdirs = Array.isArray(workdirData?.workdirs) ? workdirData.workdirs : [];
|
||||
const currentWorkdirOptions = workdirOptions(queue, savedWorkdirs, providerId, executionMode, cwd);
|
||||
const currentWorkdirSaved = currentWorkdirOptions.some((item: any) => item.source === "saved" && normalizedWorkdirPath(item.path) === normalizedWorkdirPath(cwd));
|
||||
const selectedCanSteer = selectedTask?.id && selectedTask?.activeTurnId && String(selectedTask?.status) === "running";
|
||||
const selectedCanInterrupt = selectedTask?.id && !["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
const selectedCanRetry = selectedTask?.id && ["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
@@ -2175,6 +2219,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const next = String(nextProviderId || queue?.mainProviderId || "D601").trim() || "D601";
|
||||
setProviderId(next);
|
||||
setCwd(executionModeDefaultWorkdir(queue, executionMode, next));
|
||||
setWorkdirData(null);
|
||||
void loadWorkdirs().catch((err) => setError(errorText(err, "加载工作目录失败")));
|
||||
}
|
||||
|
||||
function changeSubmitExecutionMode(nextMode: string): void {
|
||||
@@ -2189,6 +2235,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
}
|
||||
setExecutionMode(next);
|
||||
setCwd(executionModeDefaultWorkdir(queue, next, nextProvider));
|
||||
setWorkdirData(null);
|
||||
void loadWorkdirs().catch((err) => setError(errorText(err, "加载工作目录失败")));
|
||||
}
|
||||
|
||||
function patchLoadedReadState(taskIds: string[], readAt: string, queuePatch: any = null, taskPatch: any = null): void {
|
||||
@@ -2976,6 +3024,51 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkdirs(): Promise<void> {
|
||||
if (!service) return;
|
||||
const result = await requestJson(codexApi(apiBaseUrl, "/api/workdirs"));
|
||||
setWorkdirData(result);
|
||||
}
|
||||
|
||||
async function createWorkdirPreset(): Promise<void> {
|
||||
const defaultPath = cwd.trim() || currentProviderDefaultWorkdir || "/workspace";
|
||||
const proposed = typeof window === "undefined" ? defaultPath : window.prompt("输入新的工作目录绝对路径", defaultPath);
|
||||
const path = String(proposed || "").trim();
|
||||
if (!path) return;
|
||||
setWorkdirBusy(true);
|
||||
await guarded(async () => {
|
||||
const result = await requestJson(codexApi(apiBaseUrl, "/api/workdirs"), {
|
||||
method: "POST",
|
||||
body: { providerId, executionMode, path, ensure: true },
|
||||
});
|
||||
setWorkdirData((previous: any) => ({ ...(previous || {}), ...result }));
|
||||
setCwd(String(result?.workdir?.path || path));
|
||||
const msg = `已保存工作目录:${String(result?.workdir?.path || path)}`;
|
||||
setNotice(msg);
|
||||
addNotification("success", msg);
|
||||
}, "创建工作目录失败");
|
||||
setWorkdirBusy(false);
|
||||
}
|
||||
|
||||
async function deleteWorkdirPreset(): Promise<void> {
|
||||
const path = normalizedWorkdirPath(cwd);
|
||||
if (!currentWorkdirSaved) {
|
||||
setNotice("当前工作目录还没有保存到下拉菜单。");
|
||||
return;
|
||||
}
|
||||
const confirmed = typeof window === "undefined" ? true : window.confirm(`从下拉菜单删除工作目录选项?\n${path}\n\n不会删除磁盘上的实际目录。`);
|
||||
if (!confirmed) return;
|
||||
setWorkdirBusy(true);
|
||||
await guarded(async () => {
|
||||
const result = await requestJson(codexApi(apiBaseUrl, `/api/workdirs/${encodeURIComponent(providerId)}/${encodeURIComponent(executionMode)}/${encodeURIComponent(path)}`), { method: "DELETE" });
|
||||
setWorkdirData((previous: any) => ({ ...(previous || {}), ...result }));
|
||||
const msg = `已从下拉菜单删除工作目录:${path}`;
|
||||
setNotice(msg);
|
||||
addNotification("success", msg);
|
||||
}, "删除工作目录失败");
|
||||
setWorkdirBusy(false);
|
||||
}
|
||||
|
||||
async function createQueue(): Promise<void> {
|
||||
const proposed = typeof window === "undefined" ? "" : window.prompt("输入新的 Codex queue ID(字母/数字/._-,最长 64)", "new-lane");
|
||||
const nextQueueId = String(proposed || "").trim();
|
||||
@@ -3420,6 +3513,12 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
void ensureTraceSummary(taskId, true).catch((err) => setError(errorText(err, "自动加载 Trace Summary 失败")));
|
||||
}, [service?.id, selectedTask?.id, selectedTask?.updatedAt, selectedTask?.traceStats?.statsRevision, selectedTask?._traceSummaryUpdatedAt, selectedTask?._traceSummaryLoaded, selectedDetailLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!service) return undefined;
|
||||
void loadWorkdirs().catch((err) => setError(errorText(err, "加载工作目录失败")));
|
||||
return undefined;
|
||||
}, [service?.id]);
|
||||
|
||||
const taskListContent = sidebarTasks.length === 0 ? h(EmptyState, {
|
||||
title: searchActive ? (searchLoading ? "搜索中" : "没有匹配任务") : "队列为空",
|
||||
text: searchActive ? (searchLoading ? `正在搜索包含“${normalizedSearchQuery}”的 task...` : `未找到包含“${normalizedSearchQuery}”的 task;可换个关键词或切换 queue。`) : "提交一个任务后,Codex 会串行执行并保存输出。",
|
||||
@@ -3659,7 +3758,16 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
executionModeRows.map((mode: any) => h("option", { key: mode.id, value: mode.id }, `${mode.label || mode.id}${mode.id === "windows-native" ? " · 宿主 Codex" : ""}`)),
|
||||
),
|
||||
),
|
||||
h("label", null, "工作目录", h("input", { value: cwd, disabled: submitting, onChange: (event: any) => setCwd(event.target.value), placeholder: currentProviderDefaultWorkdir || queue?.defaultWorkdir || "/workspace", "data-testid": "codex-cwd-input" })),
|
||||
h("label", { className: "codex-workdir-field" }, "工作目录",
|
||||
h("div", { className: "codex-workdir-row" },
|
||||
h("input", { value: cwd, disabled: submitting, onChange: (event: any) => setCwd(event.target.value), placeholder: currentProviderDefaultWorkdir || queue?.defaultWorkdir || "/workspace", "data-testid": "codex-cwd-input" }),
|
||||
h("select", { value: normalizedWorkdirPath(cwd), disabled: submitting || workdirBusy, onChange: (event: any) => setCwd(String(event.target.value || "")), "data-testid": "codex-cwd-select" },
|
||||
currentWorkdirOptions.map((item: any) => h("option", { key: `${item.providerId}:${item.executionMode}:${item.path}`, value: item.path }, `${item.path}${item.source === "default" ? " · 默认" : ""}`)),
|
||||
),
|
||||
h("button", { type: "button", className: "ghost-btn codex-workdir-create-btn", disabled: submitting || busy || workdirBusy, onClick: () => void createWorkdirPreset(), "data-testid": "codex-cwd-create-button" }, workdirBusy ? "处理中" : "新建"),
|
||||
h("button", { type: "button", className: "ghost-btn codex-workdir-delete-btn", disabled: submitting || busy || workdirBusy || !currentWorkdirSaved, onClick: () => void deleteWorkdirPreset(), title: currentWorkdirSaved ? "从工作目录下拉菜单删除这个选项,不删除磁盘目录" : "当前工作目录尚未保存到下拉菜单", "data-testid": "codex-cwd-delete-button" }, "删除"),
|
||||
),
|
||||
),
|
||||
h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 99, value: maxAttempts, disabled: submitting, onChange: (event: any) => setMaxAttempts(Number(event.target.value)), "data-testid": "codex-max-attempts-input" })),
|
||||
h("label", null, "入队份数", h("input", { type: "number", min: 1, max: 50, value: repeatCount, disabled: submitting, onChange: (event: any) => setRepeatCount(Number(event.target.value)), "data-testid": "codex-repeat-count-input" })),
|
||||
),
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
RunMode,
|
||||
RuntimeConfig,
|
||||
TaskStatus,
|
||||
WorkdirRecord,
|
||||
} from "./types";
|
||||
import {
|
||||
codeAgentPortForModel,
|
||||
@@ -168,6 +169,7 @@ const retryBackoffBaseMs = 1000;
|
||||
const retryBackoffMaxMs = 10 * 60 * 1000;
|
||||
const queueIdPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u;
|
||||
const queueNameMaxLength = 80;
|
||||
const workdirMaxLength = 512;
|
||||
const config = readConfig();
|
||||
|
||||
const logger = createLogger("code-queue", config.logFile);
|
||||
@@ -210,6 +212,7 @@ let databaseFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let databaseFlushInFlight = false;
|
||||
const dirtyDatabaseTaskIds = new Set<string>();
|
||||
const dirtyDatabaseQueueIds = new Set<string>();
|
||||
const workdirRecords = new Map<string, WorkdirRecord>();
|
||||
|
||||
function envString(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
@@ -753,6 +756,61 @@ function safeQueueName(value: unknown, queueId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorkdirPath(value: unknown, providerId: string): string {
|
||||
const raw = typeof value === "string" ? value.trim() : "";
|
||||
if (raw.length === 0) throw new Error("workdir path is required");
|
||||
if (raw.length > workdirMaxLength) throw new Error(`workdir path must be ${workdirMaxLength} characters or fewer`);
|
||||
if (raw.includes("\u0000")) throw new Error("workdir path contains an invalid character");
|
||||
return resolveTaskCwd(providerId, raw).replace(/\/+$/u, "") || "/";
|
||||
}
|
||||
|
||||
function workdirRecordKey(providerId: string, executionMode: ReturnType<typeof normalizeCodeExecutionMode>, path: string): string {
|
||||
return `${providerId}\u0000${executionMode}\u0000${path}`;
|
||||
}
|
||||
|
||||
function sortedWorkdirRecords(): WorkdirRecord[] {
|
||||
return Array.from(workdirRecords.values()).sort((left, right) => {
|
||||
const providerDelta = left.providerId.localeCompare(right.providerId);
|
||||
if (providerDelta !== 0) return providerDelta;
|
||||
const modeDelta = left.executionMode.localeCompare(right.executionMode);
|
||||
if (modeDelta !== 0) return modeDelta;
|
||||
return left.path.localeCompare(right.path);
|
||||
});
|
||||
}
|
||||
|
||||
function rememberWorkdir(providerIdValue: unknown, executionModeValue: unknown, pathValue: unknown, timestamp = nowIso()): WorkdirRecord {
|
||||
const providerId = normalizeTaskProviderId(providerIdValue);
|
||||
const executionMode = normalizeCodeExecutionMode(executionModeValue);
|
||||
const path = normalizeWorkdirPath(pathValue, providerId);
|
||||
const key = workdirRecordKey(providerId, executionMode, path);
|
||||
const existing = workdirRecords.get(key);
|
||||
if (existing !== undefined) {
|
||||
existing.updatedAt = timestamp;
|
||||
return existing;
|
||||
}
|
||||
const record: WorkdirRecord = { providerId, executionMode, path, createdAt: timestamp, updatedAt: timestamp };
|
||||
workdirRecords.set(key, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function ensureDefaultWorkdirRecords(): void {
|
||||
const timestamp = nowIso();
|
||||
for (const provider of executionProviderOptions() as Array<Record<string, JsonValue>>) {
|
||||
const providerId = normalizeProviderId(provider.id);
|
||||
if (providerId === null) continue;
|
||||
rememberWorkdir(providerId, "default", String(provider.defaultWorkdir || defaultWorkdirForProvider(providerId)), timestamp);
|
||||
if (provider.supportsWindowsNativeCodex === true && typeof provider.windowsNativeDefaultWorkdir === "string" && provider.windowsNativeDefaultWorkdir.length > 0) {
|
||||
rememberWorkdir(providerId, "windows-native", provider.windowsNativeDefaultWorkdir, timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLocalWorkdir(path: string): { ok: boolean; created: boolean; error: string | null } {
|
||||
const existed = existsSync(path);
|
||||
mkdirSync(path, { recursive: true });
|
||||
return { ok: true, created: !existed, error: null };
|
||||
}
|
||||
|
||||
function emptyState(): PersistedState {
|
||||
const at = nowIso();
|
||||
return { version: 1, updatedAt: at, nextSeq: 1, queues: [{ id: defaultQueueId, name: defaultQueueId, createdAt: at, updatedAt: at }], tasks: [] };
|
||||
@@ -1211,6 +1269,29 @@ async function upsertQueueToDatabase(client: SqlExecutor, queue: QueueRecord): P
|
||||
`;
|
||||
}
|
||||
|
||||
async function upsertWorkdirsToDatabase(records: WorkdirRecord[]): Promise<void> {
|
||||
if (records.length === 0) return;
|
||||
for (const record of records) {
|
||||
await sql`
|
||||
INSERT INTO unidesk_code_queue_workdirs (
|
||||
provider_id,
|
||||
execution_mode,
|
||||
path,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
${record.providerId},
|
||||
${record.executionMode},
|
||||
${record.path},
|
||||
${taskTimestamp(record.createdAt) ?? nowIso()},
|
||||
${taskTimestamp(record.updatedAt) ?? nowIso()}
|
||||
)
|
||||
ON CONFLICT (provider_id, execution_mode, path) DO UPDATE SET
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
interface DatabaseTaskRow {
|
||||
id: string;
|
||||
updated_at: Date | string;
|
||||
@@ -1595,6 +1676,16 @@ async function initDatabasePersistence(): Promise<void> {
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS unidesk_code_queue_workdirs (
|
||||
provider_id TEXT NOT NULL,
|
||||
execution_mode TEXT NOT NULL DEFAULT 'default',
|
||||
path TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (provider_id, execution_mode, path)
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS unidesk_code_queue_notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1626,6 +1717,7 @@ async function initDatabasePersistence(): Promise<void> {
|
||||
AND (task_json->>'readAt') ~ '^\\d{4}-\\d{2}-\\d{2}T'
|
||||
`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_queues ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_workdirs ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default'`;
|
||||
|
||||
const countRows = await sql<Array<{ count: string | number }>>`SELECT COUNT(*) AS count FROM unidesk_code_queue_tasks`;
|
||||
const hotTasks = await loadTasksFromDatabase("hot");
|
||||
@@ -1665,6 +1757,21 @@ async function initDatabasePersistence(): Promise<void> {
|
||||
}
|
||||
}
|
||||
state.queues.splice(0, state.queues.length, ...Array.from(queueMap.values()).sort((left, right) => left.id.localeCompare(right.id)));
|
||||
const workdirRows = await sql<Array<{ provider_id: string; execution_mode: string; path: string; created_at: Date | string; updated_at: Date | string }>>`
|
||||
SELECT provider_id, execution_mode, path, created_at, updated_at
|
||||
FROM unidesk_code_queue_workdirs
|
||||
ORDER BY provider_id ASC, execution_mode ASC, path ASC
|
||||
`;
|
||||
for (const row of workdirRows) {
|
||||
rememberWorkdir(row.provider_id, row.execution_mode, row.path, taskTimestamp(String(row.updated_at)) ?? nowIso());
|
||||
const record = workdirRecords.get(workdirRecordKey(normalizeTaskProviderId(row.provider_id), normalizeCodeExecutionMode(row.execution_mode), normalizeWorkdirPath(row.path, normalizeTaskProviderId(row.provider_id))));
|
||||
if (record !== undefined) {
|
||||
record.createdAt = taskTimestamp(String(row.created_at)) ?? record.createdAt;
|
||||
record.updatedAt = taskTimestamp(String(row.updated_at)) ?? record.updatedAt;
|
||||
}
|
||||
}
|
||||
ensureDefaultWorkdirRecords();
|
||||
await upsertWorkdirsToDatabase(sortedWorkdirRecords());
|
||||
databaseReady = true;
|
||||
scheduleStartupDatabaseMaintenance();
|
||||
runGarbageCollection();
|
||||
@@ -1902,6 +2009,7 @@ function createTask(request: QueueTaskRequest): QueueTask {
|
||||
const executionMode = normalizeCodeExecutionMode(request.executionMode);
|
||||
const cwd = resolveTaskCwd(providerId, request.cwd);
|
||||
validateExecutionModeForTask(providerId, cwd, model, executionMode);
|
||||
rememberWorkdir(providerId, executionMode, cwd, at);
|
||||
const queueId = normalizeQueueId(request.queueId);
|
||||
ensureQueue(queueId);
|
||||
return {
|
||||
@@ -2137,6 +2245,7 @@ configureProviderRuntime({
|
||||
config,
|
||||
safePreview,
|
||||
});
|
||||
ensureDefaultWorkdirRecords();
|
||||
|
||||
configureTaskOutput({
|
||||
config,
|
||||
@@ -3318,9 +3427,12 @@ function requestErrorResponse(error: unknown): Response | null {
|
||||
|| error.message === "a task cannot reference itself while editing prompt"
|
||||
|| error.message === "sourceQueueId is required"
|
||||
|| error.message === "source queue must be different from target queue"
|
||||
|| error.message === "workdir path is required"
|
||||
|| error.message === "workdir path contains an invalid character"
|
||||
|| error.message.startsWith("referenceTaskIds supports at most ")
|
||||
|| error.message.startsWith("queueId must match ")
|
||||
|| error.message.startsWith("queue name must be ")
|
||||
|| error.message.startsWith("workdir path must be ")
|
||||
|| error.message.startsWith("windows-native executionMode ")
|
||||
)) {
|
||||
return jsonResponse({ ok: false, error: error.message }, 400);
|
||||
@@ -3337,6 +3449,76 @@ function parseLimit(url: URL): number {
|
||||
return Number.isInteger(value) && value > 0 ? Math.min(500, value) : 100;
|
||||
}
|
||||
|
||||
function workdirRowsForResponse(providerIdValue: string | null, executionModeValue: string | null): WorkdirRecord[] {
|
||||
const providerId = normalizeProviderId(providerIdValue) ?? null;
|
||||
const executionMode = executionModeValue === null ? null : normalizeCodeExecutionMode(executionModeValue);
|
||||
return sortedWorkdirRecords().filter((record) => {
|
||||
if (providerId !== null && record.providerId !== providerId) return false;
|
||||
if (executionMode !== null && record.executionMode !== executionMode) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function listWorkdirs(url: URL): Promise<Response> {
|
||||
ensureDefaultWorkdirRecords();
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
workdirs: workdirRowsForResponse(url.searchParams.get("providerId"), url.searchParams.get("executionMode")),
|
||||
defaultProviderId: config.mainProviderId,
|
||||
defaultWorkdir: config.defaultWorkdir,
|
||||
remoteDefaultWorkdir: config.remoteDefaultWorkdir,
|
||||
windowsNativeCodexDefaultWorkdir: config.windowsNativeCodexDefaultWorkdir,
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkdir(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
const record = typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
||||
const providerId = normalizeTaskProviderId(record.providerId);
|
||||
const executionMode = normalizeCodeExecutionMode(record.executionMode);
|
||||
const path = normalizeWorkdirPath(record.path ?? record.cwd ?? record.workdir, providerId);
|
||||
validateExecutionModeForTask(providerId, path, config.defaultModel, executionMode);
|
||||
const previous = workdirRecords.get(workdirRecordKey(providerId, executionMode, path));
|
||||
let ensureResult: JsonValue = { ok: false, skipped: true, reason: "remote provider workdirs are created when a task starts" };
|
||||
if (providerIsMain(providerId)) {
|
||||
ensureResult = ensureLocalWorkdir(path) as unknown as JsonValue;
|
||||
} else if (record.ensure === true || record.createOnProvider === true) {
|
||||
const command = await runCodeQueueSsh(providerId, `set -euo pipefail\nmkdir -p ${shellQuote(path)}\ntest -d ${shellQuote(path)}\nprintf 'workdir_ready path=%s\\n' ${shellQuote(path)}`, 30_000, "workdir-create");
|
||||
ensureResult = {
|
||||
ok: command.exitCode === 0,
|
||||
exitCode: command.exitCode,
|
||||
stdout: safePreview(command.stdout, 800),
|
||||
stderr: safePreview(command.stderr, 800),
|
||||
durationMs: command.durationMs,
|
||||
} as unknown as JsonValue;
|
||||
if (command.exitCode !== 0) return jsonResponse({ ok: false, error: `failed to create workdir on provider ${providerId}`, ensure: ensureResult }, 502);
|
||||
}
|
||||
const workdir = rememberWorkdir(providerId, executionMode, path);
|
||||
await upsertWorkdirsToDatabase([workdir]);
|
||||
logger("info", "workdir_saved", { providerId, executionMode, path, existed: previous !== undefined, ensure: ensureResult });
|
||||
return jsonResponse({ ok: true, workdir, workdirs: sortedWorkdirRecords(), ensure: ensureResult }, previous === undefined ? 201 : 200);
|
||||
}
|
||||
|
||||
async function deleteWorkdir(providerIdValue: string, executionModeValue: string, pathValue: string): Promise<Response> {
|
||||
const providerId = normalizeTaskProviderId(providerIdValue);
|
||||
const executionMode = normalizeCodeExecutionMode(executionModeValue);
|
||||
const path = normalizeWorkdirPath(pathValue, providerId);
|
||||
const key = workdirRecordKey(providerId, executionMode, path);
|
||||
const existing = workdirRecords.get(key) ?? null;
|
||||
if (existing === null) return jsonResponse({ ok: false, error: "workdir not found" }, 404);
|
||||
workdirRecords.delete(key);
|
||||
if (databaseReady) {
|
||||
await sql`
|
||||
DELETE FROM unidesk_code_queue_workdirs
|
||||
WHERE provider_id = ${providerId}
|
||||
AND execution_mode = ${executionMode}
|
||||
AND path = ${path}
|
||||
`;
|
||||
}
|
||||
logger("info", "workdir_deleted", { providerId, executionMode, path });
|
||||
return jsonResponse({ ok: true, deleted: existing, workdirs: sortedWorkdirRecords() });
|
||||
}
|
||||
|
||||
async function createTasks(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
const batchRecord = typeof body === "object" && body !== null && !Array.isArray(body) ? body as Record<string, unknown> : {};
|
||||
@@ -3358,6 +3540,7 @@ async function createTasks(req: Request): Promise<Response> {
|
||||
logger("info", "tasks_enqueued", { count: tasks.length, ids: tasks.map((task) => task.id), queueIds: Array.from(new Set(tasks.map(queueIdOf))), providerIds: Array.from(new Set(tasks.map((task) => task.providerId))), executionModes: Array.from(new Set(tasks.map((task) => task.executionMode))) });
|
||||
scheduleQueue();
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
await upsertWorkdirsToDatabase(sortedWorkdirRecords());
|
||||
return jsonResponse({ ok: true, tasks: tasks.map((task) => taskForResponse(task)), queue: await queueSummaryForResponse() }, 202);
|
||||
}
|
||||
|
||||
@@ -3964,6 +4147,16 @@ async function route(req: Request): Promise<Response> {
|
||||
const tasks = await loadAllTasksForRead();
|
||||
return jsonResponse({ ok: true, queues: perQueueSummaries(tasks), queue: queueSummary(false, tasks) });
|
||||
}
|
||||
if (url.pathname === "/api/workdirs" && req.method === "GET") return await listWorkdirs(url);
|
||||
if (url.pathname === "/api/workdirs" && req.method === "POST") return await createWorkdir(req);
|
||||
const workdirMatch = url.pathname.match(/^\/api\/workdirs\/([^/]+)\/([^/]+)\/(.+)$/u);
|
||||
if (workdirMatch !== null && req.method === "DELETE") {
|
||||
return await deleteWorkdir(
|
||||
decodeURIComponent(workdirMatch[1] ?? ""),
|
||||
decodeURIComponent(workdirMatch[2] ?? ""),
|
||||
decodeURIComponent(workdirMatch[3] ?? ""),
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/queues" && req.method === "POST") return await createQueue(req);
|
||||
if (url.pathname === "/api/queues/merge" && req.method === "POST") return await mergeQueues(null, req);
|
||||
const queueMergeMatch = url.pathname.match(/^\/api\/queues\/([^/]+)\/merge$/u);
|
||||
|
||||
@@ -385,6 +385,14 @@ export interface QueueRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkdirRecord {
|
||||
providerId: string;
|
||||
executionMode: CodeExecutionMode;
|
||||
path: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppServerExit {
|
||||
code: number | null;
|
||||
signal: string | null;
|
||||
|
||||
Reference in New Issue
Block a user