chore: checkpoint before performance tuning
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { chromium, type BrowserContext, type Request } from "playwright";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { readConfig } from "./config";
|
||||
|
||||
interface CodexQueuePerfOptions {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
timeoutMs: number;
|
||||
targetMs: number;
|
||||
headless: boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface ApiTiming {
|
||||
url: string;
|
||||
method: string;
|
||||
status: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
function argValue(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberArg(args: string[], name: string, fallback: number): number {
|
||||
const raw = argValue(args, name);
|
||||
if (raw === null) return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
return value.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function readOptions(): CodexQueuePerfOptions {
|
||||
const config = readConfig();
|
||||
const args = process.argv.slice(2);
|
||||
return {
|
||||
url: normalizeBaseUrl(argValue(args, "--url") ?? `http://${config.network.publicHost}:${config.network.frontend.port}`),
|
||||
username: argValue(args, "--username") ?? config.auth.username,
|
||||
password: argValue(args, "--password") ?? config.auth.password,
|
||||
timeoutMs: numberArg(args, "--timeout-ms", 90_000),
|
||||
targetMs: numberArg(args, "--target-ms", 1_000),
|
||||
headless: !args.includes("--headed"),
|
||||
json: args.includes("--json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function authenticateSession(context: BrowserContext, options: CodexQueuePerfOptions): Promise<void> {
|
||||
const response = await fetch(`${options.url}/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: options.username, password: options.password }),
|
||||
signal: AbortSignal.timeout(options.timeoutMs),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`login failed: HTTP ${response.status} ${await response.text()}`);
|
||||
}
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
const cookiePair = setCookie?.split(";", 1)[0] ?? "";
|
||||
const separator = cookiePair.indexOf("=");
|
||||
if (separator <= 0) throw new Error("login response did not set a session cookie");
|
||||
await context.addCookies([{
|
||||
name: cookiePair.slice(0, separator),
|
||||
value: cookiePair.slice(separator + 1),
|
||||
url: options.url,
|
||||
}]);
|
||||
}
|
||||
|
||||
function requestPath(request: Request): string {
|
||||
try {
|
||||
const url = new URL(request.url());
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return request.url();
|
||||
}
|
||||
}
|
||||
|
||||
function parseNumberAttr(value: string | null): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function runCodexQueuePerf(options: CodexQueuePerfOptions): Promise<Record<string, unknown>> {
|
||||
const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH || ".state/playwright-browsers";
|
||||
const fullChromePath = resolve(browsersPath, "chromium-1217/chrome-linux64/chrome");
|
||||
const launchArgs = [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
];
|
||||
const launchOptions: Parameters<typeof chromium.launch>[0] = { headless: options.headless, args: launchArgs };
|
||||
if (existsSync(fullChromePath)) {
|
||||
launchOptions.executablePath = fullChromePath;
|
||||
}
|
||||
const browser = await chromium.launch(launchOptions);
|
||||
const apiStartedAt = new Map<Request, number>();
|
||||
const apiTimings: ApiTiming[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const targetUrl = `${options.url}/app/codex-queue/`;
|
||||
const measuredAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await authenticateSession(context, options);
|
||||
const page = await context.newPage();
|
||||
await page.route("**/favicon.ico", (route) => route.abort());
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text().slice(0, 500));
|
||||
});
|
||||
page.on("request", (request) => {
|
||||
const path = requestPath(request);
|
||||
if (path.startsWith("/api/") || path === "/app.js") apiStartedAt.set(request, performance.now());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
const request = response.request();
|
||||
const started = apiStartedAt.get(request);
|
||||
if (started === undefined) return;
|
||||
apiStartedAt.delete(request);
|
||||
apiTimings.push({
|
||||
url: requestPath(request),
|
||||
method: request.method(),
|
||||
status: response.status(),
|
||||
durationMs: Math.round((performance.now() - started) * 10) / 10,
|
||||
});
|
||||
});
|
||||
|
||||
const startedAt = performance.now();
|
||||
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: options.timeoutMs });
|
||||
const domContentLoadedMs = Math.round((performance.now() - startedAt) * 10) / 10;
|
||||
await page.waitForSelector('[data-testid="codex-queue-page"][data-load-state="complete"]', { timeout: options.timeoutMs });
|
||||
const completeAt = performance.now();
|
||||
const dom = await page.evaluate(() => {
|
||||
const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null;
|
||||
const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null;
|
||||
const tasks = document.querySelectorAll('[data-testid^="codex-task-codex_"]');
|
||||
return {
|
||||
loadState: pageElement?.dataset.loadState ?? "",
|
||||
componentLoadMs: pageElement?.dataset.loadTotalMs ?? "",
|
||||
queueMs: pageElement?.dataset.loadQueueMs ?? "",
|
||||
detailMs: pageElement?.dataset.loadDetailMs ?? "",
|
||||
transcriptRows: pageElement?.dataset.loadTranscriptRows ?? "",
|
||||
taskId: pageElement?.dataset.loadTaskId ?? "",
|
||||
partial: pageElement?.dataset.loadPartial === "true",
|
||||
visibleTaskCount: tasks.length,
|
||||
outputChars: output?.textContent?.length ?? 0,
|
||||
};
|
||||
});
|
||||
let networkIdleReached = true;
|
||||
try {
|
||||
await page.waitForLoadState("networkidle", { timeout: 5_000 });
|
||||
} catch {
|
||||
networkIdleReached = false;
|
||||
}
|
||||
const networkIdleMs = Math.round((performance.now() - startedAt) * 10) / 10;
|
||||
const playwrightObservedMs = Math.round((completeAt - startedAt) * 10) / 10;
|
||||
const browserLoadMs = parseNumberAttr(dom.componentLoadMs);
|
||||
const totalLoadMs = browserLoadMs ?? playwrightObservedMs;
|
||||
|
||||
const slowestApi = apiTimings.slice().sort((left, right) => right.durationMs - left.durationMs).slice(0, 8);
|
||||
const result = {
|
||||
ok: true,
|
||||
measuredAt,
|
||||
url: targetUrl,
|
||||
targetMs: options.targetMs,
|
||||
withinTarget: totalLoadMs <= options.targetMs,
|
||||
status: totalLoadMs <= options.targetMs ? "passed" : "slow",
|
||||
wallMs: totalLoadMs,
|
||||
totalLoadMs,
|
||||
domContentLoadedMs,
|
||||
appCompleteMs: playwrightObservedMs,
|
||||
playwrightObservedMs,
|
||||
networkIdleMs,
|
||||
networkIdleReached,
|
||||
componentLoadMs: browserLoadMs,
|
||||
queueMs: parseNumberAttr(dom.queueMs),
|
||||
detailMs: parseNumberAttr(dom.detailMs),
|
||||
transcriptRows: parseNumberAttr(dom.transcriptRows),
|
||||
partial: dom.partial,
|
||||
selectedTaskId: dom.taskId || null,
|
||||
visibleTaskCount: dom.visibleTaskCount,
|
||||
outputChars: dom.outputChars,
|
||||
apiRequestCount: apiTimings.length,
|
||||
slowestApi,
|
||||
consoleErrors,
|
||||
};
|
||||
await context.close();
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
measuredAt,
|
||||
url: targetUrl,
|
||||
targetMs: options.targetMs,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
apiRequestCount: apiTimings.length,
|
||||
slowestApi: apiTimings.slice().sort((left, right) => right.durationMs - left.durationMs).slice(0, 8),
|
||||
consoleErrors,
|
||||
};
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const options = readOptions();
|
||||
const result = await runCodexQueuePerf(options);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
if (result.ok !== true) process.exitCode = 1;
|
||||
@@ -0,0 +1,449 @@
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
|
||||
const defaultToolLimit = 8;
|
||||
const defaultTraceLimit = 80;
|
||||
const maxTraceLimit = 500;
|
||||
const defaultOutputLimit = 20;
|
||||
const defaultTextPreviewChars = 12_000;
|
||||
|
||||
interface CodexTaskOptions {
|
||||
trace: boolean;
|
||||
traceLimit: number;
|
||||
traceMode: "tail" | "after" | "before";
|
||||
afterSeq: number;
|
||||
beforeSeq: number | null;
|
||||
toolLimit: number;
|
||||
full: boolean;
|
||||
rawSummary: boolean;
|
||||
}
|
||||
|
||||
interface CodexOutputOptions {
|
||||
limit: number;
|
||||
mode: "tail" | "after" | "before";
|
||||
afterSeq: number;
|
||||
beforeSeq: number | null;
|
||||
fullText: boolean;
|
||||
maxTextChars: number;
|
||||
}
|
||||
|
||||
type CodexResponseFetcher = (path: string) => unknown;
|
||||
type AsyncCodexResponseFetcher = (path: string) => Promise<unknown>;
|
||||
|
||||
function requireTaskId(value: string | undefined, command: string): string {
|
||||
if (value === undefined || value.trim().length === 0) throw new Error(`${command} requires task id`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function upstreamError(response: unknown): string {
|
||||
const record = asRecord(response);
|
||||
if (record === null) return String(response);
|
||||
const body = asRecord(record.body);
|
||||
const bodyError = body?.error;
|
||||
if (typeof bodyError === "string") return bodyError;
|
||||
const status = typeof record.status === "number" ? `HTTP ${record.status}` : "upstream request failed";
|
||||
return `${status}: ${JSON.stringify(response).slice(0, 1200)}`;
|
||||
}
|
||||
|
||||
function unwrapCodexResponse(response: unknown): { upstream: { ok: unknown; status: unknown }; body: Record<string, unknown> } {
|
||||
const record = asRecord(response);
|
||||
if (record?.ok !== true) throw new Error(upstreamError(response));
|
||||
const body = asRecord(record.body);
|
||||
if (body?.ok !== true) throw new Error(upstreamError(response));
|
||||
return { upstream: { ok: record.ok, status: record.status }, body };
|
||||
}
|
||||
|
||||
function positiveIntegerOption(args: string[], names: string[], defaultValue: number, maxValue = Number.MAX_SAFE_INTEGER): number {
|
||||
for (const name of names) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) continue;
|
||||
const raw = args[index + 1];
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
||||
return Math.min(value, maxValue);
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function nonNegativeNumberOption(args: string[], names: string[], defaultValue: number): number {
|
||||
for (const name of names) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) continue;
|
||||
const raw = args[index + 1];
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error(`${name} must be a non-negative number`);
|
||||
return value;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function nullablePositiveNumberOption(args: string[], names: string[]): number | null {
|
||||
for (const name of names) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) continue;
|
||||
const raw = args[index + 1];
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], name: string): boolean {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
function textView(text: string, full: boolean, maxChars: number): Record<string, unknown> {
|
||||
const truncated = !full && text.length > maxChars;
|
||||
return {
|
||||
text: truncated ? text.slice(0, maxChars) : text,
|
||||
chars: text.length,
|
||||
truncated,
|
||||
omittedChars: truncated ? text.length - maxChars : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function compactText(text: unknown, full: boolean, maxChars: number): Record<string, unknown> {
|
||||
return textView(asString(text), full, maxChars);
|
||||
}
|
||||
|
||||
function compactLastAssistant(value: unknown, full: boolean): Record<string, unknown> {
|
||||
const record = asRecord(value) ?? {};
|
||||
return {
|
||||
at: record.at ?? null,
|
||||
seq: record.seq ?? null,
|
||||
source: record.source ?? "none",
|
||||
...textView(asString(record.text), full, 4000),
|
||||
};
|
||||
}
|
||||
|
||||
function compactToolSummary(value: unknown, full: boolean): Record<string, unknown> {
|
||||
const record = asRecord(value) ?? {};
|
||||
const items = asArray(record.items).map((item) => {
|
||||
const line = asRecord(item) ?? {};
|
||||
return {
|
||||
seq: line.seq ?? null,
|
||||
at: line.at ?? null,
|
||||
kind: line.kind ?? null,
|
||||
title: line.title ?? "",
|
||||
status: line.status ?? null,
|
||||
commandPreview: compactText(line.commandPreview, full, 1200),
|
||||
commandOmittedLines: line.commandOmittedLines ?? 0,
|
||||
outputPreview: compactText(line.outputPreview, full, 800),
|
||||
outputOmittedLines: line.outputOmittedLines ?? 0,
|
||||
rawSeqs: line.rawSeqs ?? [],
|
||||
};
|
||||
});
|
||||
return {
|
||||
count: record.count ?? 0,
|
||||
returned: record.returned ?? items.length,
|
||||
limit: record.limit ?? items.length,
|
||||
truncated: record.truncated ?? false,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
function compactSummary(summary: unknown, options: CodexTaskOptions, taskId: string): Record<string, unknown> {
|
||||
const record = asRecord(summary) ?? {};
|
||||
const transcriptCount = asNumber(record.transcriptCount, 0);
|
||||
const transcriptMaxSeq = transcriptCount > 0 ? record.transcriptMaxSeq ?? null : null;
|
||||
const initialPrompt = asString(record.initialPrompt ?? record.prompt);
|
||||
return {
|
||||
id: record.id ?? taskId,
|
||||
queueId: record.queueId ?? null,
|
||||
status: record.status ?? null,
|
||||
model: record.model ?? null,
|
||||
reasoningEffort: record.reasoningEffort ?? null,
|
||||
cwd: record.cwd ?? null,
|
||||
attempts: {
|
||||
currentAttempt: record.currentAttempt ?? null,
|
||||
maxAttempts: record.maxAttempts ?? null,
|
||||
currentMode: record.currentMode ?? null,
|
||||
judgeFailCount: record.judgeFailCount ?? null,
|
||||
judgeFailRetryLimit: record.judgeFailRetryLimit ?? null,
|
||||
attemptRecords: record.attempts ?? [],
|
||||
},
|
||||
thread: {
|
||||
codexThreadId: record.codexThreadId ?? null,
|
||||
activeTurnId: record.activeTurnId ?? null,
|
||||
cancelRequested: record.cancelRequested ?? null,
|
||||
},
|
||||
timing: record.timing ?? null,
|
||||
createdAt: record.createdAt ?? null,
|
||||
startedAt: record.startedAt ?? null,
|
||||
updatedAt: record.updatedAt ?? null,
|
||||
finishedAt: record.finishedAt ?? null,
|
||||
initialPrompt: textView(initialPrompt, options.full, 3000),
|
||||
basePrompt: textView(asString(record.basePrompt), options.full, 2000),
|
||||
referenceTaskIds: record.referenceTaskIds ?? [],
|
||||
referenceInjection: record.referenceInjection ?? null,
|
||||
lastAssistantMessage: compactLastAssistant(record.lastAssistantMessage, options.full),
|
||||
lastJudge: record.lastJudge ?? null,
|
||||
lastError: record.lastError ?? null,
|
||||
toolSummary: compactToolSummary(record.toolSummary, options.full),
|
||||
counts: {
|
||||
transcript: record.transcriptCount ?? null,
|
||||
output: record.outputCount ?? null,
|
||||
events: record.eventCount ?? null,
|
||||
},
|
||||
traceDisclosure: {
|
||||
included: options.trace,
|
||||
total: record.transcriptCount ?? null,
|
||||
maxSeq: transcriptMaxSeq,
|
||||
defaultPage: `bun scripts/cli.ts codex task ${taskId} --trace --limit ${defaultTraceLimit}`,
|
||||
firstPage: `bun scripts/cli.ts codex task ${taskId} --trace --from-start --limit ${defaultTraceLimit}`,
|
||||
nextPageTemplate: `bun scripts/cli.ts codex task ${taskId} --trace --after-seq <nextAfterSeq> --limit ${defaultTraceLimit}`,
|
||||
previousPageTemplate: `bun scripts/cli.ts codex task ${taskId} --trace --before-seq <previousBeforeSeq> --limit ${defaultTraceLimit}`,
|
||||
rawOutputTemplate: `bun scripts/cli.ts codex output ${taskId} --after-seq <rawSeq> --limit ${defaultOutputLimit}`,
|
||||
fullTextSummary: `bun scripts/cli.ts codex task ${taskId} --full --tool-limit ${Math.max(options.toolLimit, defaultToolLimit)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compactTracePage(body: Record<string, unknown>, taskId: string, limit: number): Record<string, unknown> {
|
||||
const transcript = asArray(body.transcript);
|
||||
const nextAfterSeq = body.nextAfterSeq ?? null;
|
||||
const previousBeforeSeq = body.previousBeforeSeq ?? null;
|
||||
const omittedInPage = transcript.some((item) => {
|
||||
const line = asRecord(item) ?? {};
|
||||
return asNumber(line.bodyOmittedLines, 0) > 0 || asNumber(line.commandOmittedLines, 0) > 0;
|
||||
});
|
||||
return {
|
||||
taskId: body.taskId ?? taskId,
|
||||
queueId: body.queueId ?? null,
|
||||
status: body.status ?? null,
|
||||
updatedAt: body.updatedAt ?? null,
|
||||
mode: body.mode ?? null,
|
||||
limit,
|
||||
returned: transcript.length,
|
||||
total: body.total ?? null,
|
||||
maxSeq: body.maxSeq ?? null,
|
||||
afterSeq: body.afterSeq ?? null,
|
||||
nextAfterSeq,
|
||||
beforeSeq: body.beforeSeq ?? null,
|
||||
previousBeforeSeq,
|
||||
hasMore: body.hasMore ?? false,
|
||||
hasBefore: body.hasBefore ?? false,
|
||||
transcript,
|
||||
commands: {
|
||||
next: body.hasMore === true && nextAfterSeq !== null ? `bun scripts/cli.ts codex task ${taskId} --trace --after-seq ${nextAfterSeq} --limit ${limit}` : null,
|
||||
previous: body.hasBefore === true && previousBeforeSeq !== null ? `bun scripts/cli.ts codex task ${taskId} --trace --before-seq ${previousBeforeSeq} --limit ${limit}` : null,
|
||||
tail: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${limit}`,
|
||||
first: `bun scripts/cli.ts codex task ${taskId} --trace --from-start --limit ${limit}`,
|
||||
rawOutput: omittedInPage ? `Use rawSeqs on each transcript line, e.g. bun scripts/cli.ts codex output ${taskId} --after-seq <rawSeqBefore> --limit ${defaultOutputLimit}` : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTaskOptions(args: string[]): CodexTaskOptions {
|
||||
const beforeSeq = nullablePositiveNumberOption(args, ["--before-seq", "--beforeSeq"]);
|
||||
const afterSeq = nonNegativeNumberOption(args, ["--after-seq", "--afterSeq"], 0);
|
||||
const fromStart = hasFlag(args, "--from-start") || hasFlag(args, "--first");
|
||||
const trace = hasFlag(args, "--trace") || beforeSeq !== null || args.some((arg) => arg === "--after-seq" || arg === "--afterSeq") || fromStart || hasFlag(args, "--tail");
|
||||
const traceMode = beforeSeq !== null ? "before" : fromStart || args.some((arg) => arg === "--after-seq" || arg === "--afterSeq") ? "after" : "tail";
|
||||
return {
|
||||
trace,
|
||||
traceLimit: positiveIntegerOption(args, ["--trace-limit", "--limit"], defaultTraceLimit, maxTraceLimit),
|
||||
traceMode,
|
||||
afterSeq,
|
||||
beforeSeq,
|
||||
toolLimit: positiveIntegerOption(args, ["--tool-limit"], defaultToolLimit, 500),
|
||||
full: hasFlag(args, "--full") || hasFlag(args, "--raw-summary"),
|
||||
rawSummary: hasFlag(args, "--raw-summary"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOutputOptions(args: string[]): CodexOutputOptions {
|
||||
const beforeSeq = nullablePositiveNumberOption(args, ["--before-seq", "--beforeSeq"]);
|
||||
const afterSeq = nonNegativeNumberOption(args, ["--after-seq", "--afterSeq"], 0);
|
||||
const fromStart = hasFlag(args, "--from-start") || hasFlag(args, "--first");
|
||||
const mode = beforeSeq !== null ? "before" : fromStart || args.some((arg) => arg === "--after-seq" || arg === "--afterSeq") ? "after" : "tail";
|
||||
return {
|
||||
limit: positiveIntegerOption(args, ["--limit"], defaultOutputLimit, maxTraceLimit),
|
||||
mode,
|
||||
afterSeq,
|
||||
beforeSeq,
|
||||
fullText: hasFlag(args, "--full-text") || hasFlag(args, "--raw"),
|
||||
maxTextChars: positiveIntegerOption(args, ["--max-text-chars"], defaultTextPreviewChars, 500_000),
|
||||
};
|
||||
}
|
||||
|
||||
function queryString(params: Record<string, string | number | boolean | null | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) search.set(key, String(value));
|
||||
}
|
||||
const text = search.toString();
|
||||
return text.length > 0 ? `?${text}` : "";
|
||||
}
|
||||
|
||||
function codexTaskSummary(taskId: string, options: CodexTaskOptions, fetcher: CodexResponseFetcher): unknown {
|
||||
const summaryPath = `/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`;
|
||||
const summaryResponse = unwrapCodexResponse(fetcher(summaryPath));
|
||||
const summary = summaryResponse.body.summary;
|
||||
const result: Record<string, unknown> = {
|
||||
upstream: summaryResponse.upstream,
|
||||
summary: compactSummary(summary, options, taskId),
|
||||
};
|
||||
if (options.rawSummary) result.rawSummary = summary;
|
||||
if (options.trace) {
|
||||
const traceParams: Record<string, string | number | boolean | null> = { limit: options.traceLimit };
|
||||
if (options.traceMode === "tail") traceParams.tail = 1;
|
||||
if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq;
|
||||
if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq;
|
||||
const traceResponse = unwrapCodexResponse(fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/transcript${queryString(traceParams)}`));
|
||||
result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function compactOutputPage(body: Record<string, unknown>, taskId: string, limit: number): Record<string, unknown> {
|
||||
const output = asArray(body.output);
|
||||
const nextAfterSeq = body.nextAfterSeq ?? null;
|
||||
const previousBeforeSeq = body.previousBeforeSeq ?? null;
|
||||
return {
|
||||
taskId: body.taskId ?? taskId,
|
||||
queueId: body.queueId ?? null,
|
||||
status: body.status ?? null,
|
||||
updatedAt: body.updatedAt ?? null,
|
||||
mode: body.mode ?? null,
|
||||
limit,
|
||||
returned: output.length,
|
||||
total: body.total ?? null,
|
||||
maxSeq: body.maxSeq ?? null,
|
||||
afterSeq: body.afterSeq ?? null,
|
||||
nextAfterSeq,
|
||||
beforeSeq: body.beforeSeq ?? null,
|
||||
previousBeforeSeq,
|
||||
hasMore: body.hasMore ?? false,
|
||||
hasBefore: body.hasBefore ?? false,
|
||||
output,
|
||||
commands: {
|
||||
next: body.hasMore === true && nextAfterSeq !== null ? `bun scripts/cli.ts codex output ${taskId} --after-seq ${nextAfterSeq} --limit ${limit}` : null,
|
||||
previous: body.hasBefore === true && previousBeforeSeq !== null ? `bun scripts/cli.ts codex output ${taskId} --before-seq ${previousBeforeSeq} --limit ${limit}` : null,
|
||||
tail: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${limit}`,
|
||||
first: `bun scripts/cli.ts codex output ${taskId} --from-start --limit ${limit}`,
|
||||
fullText: `bun scripts/cli.ts codex output ${taskId} --after-seq <rawSeqBefore> --limit ${limit} --full-text`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function codexTaskOutput(taskId: string, options: CodexOutputOptions, fetcher: CodexResponseFetcher): unknown {
|
||||
const params: Record<string, string | number | boolean | null> = {
|
||||
limit: options.limit,
|
||||
fullText: options.fullText ? 1 : 0,
|
||||
maxTextChars: options.maxTextChars,
|
||||
};
|
||||
if (options.mode === "tail") params.tail = 1;
|
||||
if (options.mode === "after") params.afterSeq = options.afterSeq;
|
||||
if (options.mode === "before") params.beforeSeq = options.beforeSeq;
|
||||
const response = unwrapCodexResponse(fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`));
|
||||
return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) };
|
||||
}
|
||||
|
||||
export function codexTaskQuery(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
return codexTaskSummary(taskId, parseTaskOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
export function codexOutputQuery(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
return codexTaskOutput(taskId, parseOutputOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const summaryPath = `/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`;
|
||||
const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath));
|
||||
const summary = summaryResponse.body.summary;
|
||||
const result: Record<string, unknown> = {
|
||||
upstream: summaryResponse.upstream,
|
||||
summary: compactSummary(summary, options, taskId),
|
||||
};
|
||||
if (options.rawSummary) result.rawSummary = summary;
|
||||
if (options.trace) {
|
||||
const traceParams: Record<string, string | number | boolean | null> = { limit: options.traceLimit };
|
||||
if (options.traceMode === "tail") traceParams.tail = 1;
|
||||
if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq;
|
||||
if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq;
|
||||
const traceResponse = unwrapCodexResponse(await fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/transcript${queryString(traceParams)}`));
|
||||
result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function codexTaskOutputAsync(taskId: string, options: CodexOutputOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const params: Record<string, string | number | boolean | null> = {
|
||||
limit: options.limit,
|
||||
fullText: options.fullText ? 1 : 0,
|
||||
maxTextChars: options.maxTextChars,
|
||||
};
|
||||
if (options.mode === "tail") params.tail = 1;
|
||||
if (options.mode === "after") params.afterSeq = options.afterSeq;
|
||||
if (options.mode === "before") params.beforeSeq = options.beforeSeq;
|
||||
const response = unwrapCodexResponse(await fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`));
|
||||
return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) };
|
||||
}
|
||||
|
||||
export async function codexTaskQueryAsync(taskId: string, optionArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
return codexTaskSummaryAsync(taskId, parseTaskOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
export async function codexOutputQueryAsync(taskId: string, optionArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
return codexTaskOutputAsync(taskId, parseOutputOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
function requireQueueId(args: string[], command: string): string {
|
||||
const index = args.indexOf("--queue");
|
||||
const raw = index === -1 ? args[0] : args[index + 1];
|
||||
if (raw === undefined || raw.trim().length === 0) throw new Error(`${command} requires queue id, for example --queue default`);
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
function codexQueues(): unknown {
|
||||
return unwrapCodexResponse(coreInternalFetch("/api/microservices/codex-queue/proxy/api/queues"));
|
||||
}
|
||||
|
||||
function codexCreateQueue(queueId: string): unknown {
|
||||
return unwrapCodexResponse(coreInternalFetch("/api/microservices/codex-queue/proxy/api/queues", { method: "POST", body: { queueId } }));
|
||||
}
|
||||
|
||||
function codexMoveTask(taskId: string, queueId: string): unknown {
|
||||
return unwrapCodexResponse(coreInternalFetch(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/move`, { method: "POST", body: { queueId } }));
|
||||
}
|
||||
|
||||
export async function runCodexQueueCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "task", taskIdArg] = args;
|
||||
if (action === "task" || action === "summary" || action === "show") {
|
||||
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
|
||||
return codexTaskQuery(taskId, args.slice(2));
|
||||
}
|
||||
if (action === "output") {
|
||||
const taskId = requireTaskId(taskIdArg, "codex output");
|
||||
return codexOutputQuery(taskId, args.slice(2));
|
||||
}
|
||||
if (action === "queues") return codexQueues();
|
||||
if (action === "queue") {
|
||||
const sub = taskIdArg ?? "list";
|
||||
if (sub === "list") return codexQueues();
|
||||
if (sub === "create") return codexCreateQueue(requireQueueId(args.slice(2), "codex queue create"));
|
||||
}
|
||||
if (action === "move") {
|
||||
const taskId = requireTaskId(taskIdArg, "codex move");
|
||||
return codexMoveTask(taskId, requireQueueId(args.slice(2), "codex move"));
|
||||
}
|
||||
throw new Error("codex command must be one of: task, summary, show, output, queues, queue list, queue create, move");
|
||||
}
|
||||
+94
-18
@@ -18,7 +18,7 @@ export interface ContainerStatus {
|
||||
ports: string;
|
||||
}
|
||||
|
||||
const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note", "codex-queue"] as const;
|
||||
const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note", "codex-queue", "project-manager"] as const;
|
||||
export type RebuildableService = typeof rebuildableServices[number];
|
||||
|
||||
export function isRebuildableService(value: string | undefined): value is RebuildableService {
|
||||
@@ -112,10 +112,29 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
|
||||
UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port),
|
||||
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
|
||||
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED") || "true",
|
||||
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy",
|
||||
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE") || "private",
|
||||
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID") || "645275593",
|
||||
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID"),
|
||||
UNIDESK_TODO_NOTE_REMINDER_LEAD_MINUTES: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_LEAD_MINUTES") || "10",
|
||||
UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS") || "30000",
|
||||
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS") || "15000",
|
||||
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS") || "3",
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY"),
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_MODEL: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_MODEL") || "MiniMax-M2.7",
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE") || "https://api.minimaxi.com/v1",
|
||||
UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS") || "60000",
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED") || "true",
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy",
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE") || "private",
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID") || "645275593",
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID"),
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS") || "12000",
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS") || "15000",
|
||||
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS") || "3",
|
||||
OPENAI_API_KEY: runtimeSecret("OPENAI_API_KEY"),
|
||||
CRS_OAI_KEY: runtimeSecret("CRS_OAI_KEY"),
|
||||
};
|
||||
writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8");
|
||||
return { envFile, logDir, logPrefix };
|
||||
@@ -136,17 +155,17 @@ export function startStack(config: UniDeskConfig): unknown {
|
||||
if (occupiedPorts.length > 0 && containers.length === 0) {
|
||||
throw new Error(`Fixed UniDesk port is occupied before start: ${occupiedPorts.map((p) => `${p.name}:${p.port}`).join(", ")}`);
|
||||
}
|
||||
const downCommand = [...compose, "down", "--remove-orphans"];
|
||||
const upCommand = [...compose, "up", "-d", "--build"];
|
||||
const command = ["bash", "-lc", `set -euo pipefail; ${shellJoin(downCommand)}; ${shellJoin(upCommand)}`];
|
||||
const job = startJob("server_start", command, "Build and start UniDesk database, core, frontend, provider gateway, and managed microservice containers");
|
||||
const upCommand = [...compose, "up", "-d", "--build", "--remove-orphans"];
|
||||
const command = ["bash", "-lc", composeLockedScript(`set -euo pipefail; ${shellJoin(upCommand)}`)];
|
||||
const job = startJob("server_start", command, "Build and start UniDesk services without tearing down running queue containers");
|
||||
return { job, runtimeEnv, command, ports: fixedPorts(config) };
|
||||
}
|
||||
|
||||
export function stopStack(config: UniDeskConfig): unknown {
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
|
||||
const command = [...compose, "down", "--remove-orphans"];
|
||||
const downCommand = [...compose, "down", "--remove-orphans"];
|
||||
const command = ["bash", "-lc", composeLockedScript(`set -euo pipefail; ${shellJoin(downCommand)}`)];
|
||||
const job = startJob("server_stop", command, "Stop all UniDesk Docker services managed by the fixed compose project");
|
||||
return { job, runtimeEnv, command, portsBeforeStop: fixedPorts(config) };
|
||||
}
|
||||
@@ -158,7 +177,7 @@ export function rebuildService(config: UniDeskConfig, service: RebuildableServic
|
||||
const listServiceContainersCommand = [
|
||||
"docker",
|
||||
"ps",
|
||||
"-aq",
|
||||
"-q",
|
||||
"--filter",
|
||||
`label=com.docker.compose.project=${config.docker.projectName}`,
|
||||
"--filter",
|
||||
@@ -166,29 +185,65 @@ export function rebuildService(config: UniDeskConfig, service: RebuildableServic
|
||||
"--filter",
|
||||
"label=com.docker.compose.oneoff=False",
|
||||
];
|
||||
const upCommand = [...compose, "up", "-d", "--no-deps", service];
|
||||
const upCommand = [...compose, "up", "-d", "--no-deps", "--force-recreate", service];
|
||||
const restoreCommand = [...compose, "up", "-d", "--no-deps", service];
|
||||
const listAllServiceContainersCommand = [...listServiceContainersCommand];
|
||||
listAllServiceContainersCommand[2] = "-a";
|
||||
const lockPath = composeLockPath();
|
||||
const watchdogLog = rootPath(".state", "jobs", "compose-rebuild-watchdog.log");
|
||||
const watchdogInnerScript = [
|
||||
"set -euo pipefail",
|
||||
"sleep 20",
|
||||
`cid=$(${shellJoin(listServiceContainersCommand)} || true)`,
|
||||
`if [ -z "$cid" ]; then echo "$(date -Is) compose_rebuild_watchdog_restore service=${service}" >> ${shellQuote(watchdogLog)}; ${shellJoin(restoreCommand)} >> ${shellQuote(watchdogLog)} 2>&1 || true; fi`,
|
||||
].join("\n");
|
||||
const watchdogScript = `set -euo pipefail; ${shellJoin(["flock", "-w", "300", lockPath, "bash", "-lc", watchdogInnerScript])} || true`;
|
||||
const validateScript = [
|
||||
"ready=0",
|
||||
"for attempt in $(seq 1 60); do",
|
||||
`cid=$(${shellJoin(listServiceContainersCommand)} || true)`,
|
||||
"if [ -n \"$cid\" ]; then",
|
||||
"health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $cid 2>/dev/null | head -1 || true)",
|
||||
`echo "service_container_probe service=${service} attempt=$attempt cid=$cid health=$health"`,
|
||||
"if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
|
||||
"else",
|
||||
`echo "service_container_probe service=${service} attempt=$attempt cid=missing"`,
|
||||
"fi",
|
||||
"sleep 1",
|
||||
"done",
|
||||
"if [ \"$ready\" != \"1\" ]; then",
|
||||
`echo "service_container_not_ready service=${service}" >&2`,
|
||||
`${shellJoin(listAllServiceContainersCommand)} --format '{{.ID}} {{.Names}} {{.Status}}' >&2 || true`,
|
||||
"exit 1",
|
||||
"fi",
|
||||
].join("\n");
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`echo ${shellJoin(["rebuild_service", service, "build_first_then_replace_container"])}`,
|
||||
`echo ${shellJoin(["rebuild_service", service, "build_first_then_force_recreate_with_validation"])}`,
|
||||
shellJoin(buildCommand),
|
||||
`ids=$(${shellJoin(listServiceContainersCommand)})`,
|
||||
`if [ -n "$ids" ]; then echo "remove_existing_compose_service_containers service=${service} ids=$ids"; docker rm -f $ids; else echo "no_existing_compose_service_containers service=${service}"; fi`,
|
||||
`nohup bash -lc ${shellQuote(watchdogScript)} >/dev/null 2>&1 &`,
|
||||
shellJoin(upCommand),
|
||||
].join("; ");
|
||||
const command = ["bash", "-lc", script];
|
||||
const job = startJob("server_rebuild", command, `Rebuild and replace UniDesk ${service} without Docker Compose v1 recreate`);
|
||||
validateScript,
|
||||
].join("\n");
|
||||
const command = ["bash", "-lc", composeLockedScript(script)];
|
||||
const jobRunner = service === "codex-queue" ? { runner: "docker" as const, dockerImage: "unidesk-codex-queue:latest" } : {};
|
||||
const job = startJob("server_rebuild", command, `Rebuild and validate UniDesk ${service} with serialized Docker Compose mutation`, jobRunner);
|
||||
return {
|
||||
job,
|
||||
runtimeEnv,
|
||||
service,
|
||||
command,
|
||||
strategy: {
|
||||
buildBeforeRemove: true,
|
||||
removeScope: {
|
||||
buildBeforeReplace: true,
|
||||
replaceScope: {
|
||||
projectLabel: config.docker.projectName,
|
||||
serviceLabel: service,
|
||||
},
|
||||
noDeps: true,
|
||||
forceRecreate: true,
|
||||
composeMutationLock: rootPath(".state", "locks", "server-compose.lock"),
|
||||
jobRunner: service === "codex-queue" ? "docker" : "local",
|
||||
postUpValidation: true,
|
||||
namedVolumesPreserved: true,
|
||||
},
|
||||
};
|
||||
@@ -202,7 +257,27 @@ function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number;
|
||||
}
|
||||
|
||||
function shellJoin(args: string[]): string {
|
||||
return args.map((arg) => `'${arg.replace(/'/g, `'\\''`)}'`).join(" ");
|
||||
return args.map(shellQuote).join(" ");
|
||||
}
|
||||
|
||||
function shellQuote(arg: string): string {
|
||||
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function composeLockedScript(innerScript: string): string {
|
||||
const lockPath = composeLockPath();
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`mkdir -p ${shellQuote(rootPath(".state", "locks"))}`,
|
||||
`echo ${shellJoin(["compose_lock_wait", lockPath])}`,
|
||||
shellJoin(["flock", lockPath, "bash", "-lc", innerScript]),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function composeLockPath(): string {
|
||||
const lockDir = rootPath(".state", "locks");
|
||||
mkdirSync(lockDir, { recursive: true });
|
||||
return join(lockDir, "server-compose.lock");
|
||||
}
|
||||
|
||||
function isPortListening(port: number): boolean {
|
||||
@@ -278,6 +353,7 @@ export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
|
||||
{ name: "backend-core", containerPort: config.network.core.containerPort, hostPort: null },
|
||||
{ name: "database", containerPort: config.network.database.containerPort, hostPort: null },
|
||||
{ name: "codex-queue", containerPort: 4222, hostPort: null },
|
||||
{ name: "project-manager", containerPort: 4233, hostPort: null },
|
||||
],
|
||||
containers: dockerContainers(config),
|
||||
health: {
|
||||
@@ -315,7 +391,7 @@ export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown {
|
||||
const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix));
|
||||
const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12);
|
||||
const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) }));
|
||||
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "codex-queue-backend"];
|
||||
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "codex-queue-backend", "project-manager-backend"];
|
||||
const docker = containerNames.map((name) => {
|
||||
const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot);
|
||||
return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) };
|
||||
|
||||
+826
-70
File diff suppressed because it is too large
Load Diff
+53
-2
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
@@ -12,6 +12,9 @@ export interface JobRecord {
|
||||
status: JobStatus;
|
||||
command: string[];
|
||||
cwd: string;
|
||||
runner: "local" | "docker";
|
||||
runnerPid?: number | null;
|
||||
runnerContainer?: string | null;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
@@ -21,6 +24,11 @@ export interface JobRecord {
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface StartJobOptions {
|
||||
runner?: "local" | "docker";
|
||||
dockerImage?: string;
|
||||
}
|
||||
|
||||
function jobsDir(): string {
|
||||
const dir = rootPath(".state", "jobs");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
@@ -48,16 +56,20 @@ export function listJobs(): JobRecord[] {
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export function startJob(name: string, command: string[], note: string): JobRecord {
|
||||
export function startJob(name: string, command: string[], note: string, options: StartJobOptions = {}): JobRecord {
|
||||
const id = `${name}_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(16).slice(2, 8)}`;
|
||||
const stdoutFile = rootPath(".state", "jobs", `${id}.stdout.log`);
|
||||
const stderrFile = rootPath(".state", "jobs", `${id}.stderr.log`);
|
||||
const runner = options.runner ?? "local";
|
||||
const job: JobRecord = {
|
||||
id,
|
||||
name,
|
||||
status: "queued",
|
||||
command,
|
||||
cwd: repoRoot,
|
||||
runner,
|
||||
runnerPid: null,
|
||||
runnerContainer: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
@@ -67,12 +79,50 @@ export function startJob(name: string, command: string[], note: string): JobReco
|
||||
note,
|
||||
};
|
||||
writeJob(job);
|
||||
if (runner === "docker") {
|
||||
const containerName = `unidesk-job-runner-${id}`.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 120);
|
||||
job.runnerContainer = containerName;
|
||||
writeJob(job);
|
||||
const dockerArgs = [
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
containerName,
|
||||
"-v",
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"-v",
|
||||
`${repoRoot}:${repoRoot}`,
|
||||
"-w",
|
||||
repoRoot,
|
||||
"--entrypoint",
|
||||
"bun",
|
||||
options.dockerImage ?? "unidesk-codex-queue:latest",
|
||||
rootPath("scripts", "cli.ts"),
|
||||
"internal",
|
||||
"run-job",
|
||||
id,
|
||||
];
|
||||
const result = spawnSync("docker", dockerArgs, { cwd: repoRoot, encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
job.status = "failed";
|
||||
job.startedAt = new Date().toISOString();
|
||||
job.finishedAt = new Date().toISOString();
|
||||
job.exitCode = result.status ?? 127;
|
||||
writeFileSync(stderrFile, result.stderr || result.error?.message || "failed to start docker job runner\n", "utf8");
|
||||
writeFileSync(stdoutFile, result.stdout || "", "utf8");
|
||||
writeJob(job);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
const child = spawn(process.execPath, [rootPath("scripts", "cli.ts"), "internal", "run-job", id], {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
});
|
||||
job.runnerPid = child.pid ?? null;
|
||||
writeJob(job);
|
||||
child.unref();
|
||||
return job;
|
||||
}
|
||||
@@ -80,6 +130,7 @@ export function startJob(name: string, command: string[], note: string): JobReco
|
||||
export async function runJob(id: string): Promise<JobRecord> {
|
||||
const job = readJob(id);
|
||||
job.status = "running";
|
||||
job.runnerPid = process.pid;
|
||||
job.startedAt = new Date().toISOString();
|
||||
writeJob(job);
|
||||
const exitCode = await runCommandToFiles(job.command, job.cwd, job.stdoutFile, job.stderrFile);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { jsonByteLength, previewJson } from "./preview";
|
||||
|
||||
function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown {
|
||||
export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown {
|
||||
if (!path.startsWith("/")) throw new Error("core internal path must start with /");
|
||||
const code = `
|
||||
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({
|
||||
|
||||
+118
-3
@@ -2,7 +2,8 @@ import { spawn } from "node:child_process";
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import { parseSshArgs } from "./ssh";
|
||||
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
|
||||
import { codexOutputQueryAsync, codexTaskQueryAsync } from "./codex-queue";
|
||||
|
||||
export interface RemoteCliOptions {
|
||||
host: string | null;
|
||||
@@ -236,6 +237,95 @@ function jsonOption(args: string[], name: string): Record<string, unknown> | und
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const compactSkillDiscoverPython = String.raw`import os,json,socket,platform,getpass
|
||||
from pathlib import Path as P
|
||||
S=os.getenv('S','all');L=int(os.getenv('L','0'));D=int(os.getenv('D','4'));skip={'node_modules','.git','.state','logs','references','__pycache__'}
|
||||
def isw():
|
||||
try:r=P('/proc/sys/kernel/osrelease').read_text(errors='ignore').lower()
|
||||
except Exception:r=''
|
||||
return 'microsoft' in r or 'wsl' in r or 'WSL_INTEROP' in os.environ
|
||||
def wp(p):
|
||||
s=str(p)
|
||||
return s[5].upper()+':\\'+s[7:].replace('/','\\') if s.startswith('/mnt/') and len(s)>6 and s[5].isalpha() and s[6]=='/' else None
|
||||
def md(f):
|
||||
n=f.parent.name;d=''
|
||||
try:ls=f.read_text(errors='replace')[:8192].splitlines()
|
||||
except Exception:ls=[]
|
||||
if ls and ls[0].strip()=='---':
|
||||
for l in ls[1:]:
|
||||
if l.strip()=='---':break
|
||||
if ':' in l:
|
||||
k,v=l.split(':',1);k=k.strip().lower();v=v.strip().strip('"\' ')
|
||||
if k=='name' and v:n=v
|
||||
if k=='description' and v:d=v
|
||||
if not d:
|
||||
for l in ls:
|
||||
x=l.strip()
|
||||
if x and not x.startswith('---') and not x.startswith('#'):
|
||||
d=x;break
|
||||
return n,d
|
||||
def scan(sc,root):
|
||||
rec={'scope':sc,'path':str(root),'windowsPath':wp(root),'exists':False,'skillCount':0,'error':None};out=[]
|
||||
try:rec['exists']=root.exists()
|
||||
except Exception as e:rec['error']=str(e)
|
||||
if not rec['exists']:return rec,out
|
||||
try:
|
||||
for f in root.rglob('SKILL.md'):
|
||||
rel=f.relative_to(root).parts[:-1]
|
||||
if not rel or len(rel)>D or any(x in skip for x in rel):continue
|
||||
n,d=md(f);out.append({'scope':sc,'name':n,'description':d,'path':str(f.parent),'skillMd':str(f),'windowsPath':wp(f.parent),'root':str(root)})
|
||||
except Exception as e:rec['error']=str(e)
|
||||
rec['skillCount']=len(out);return rec,out
|
||||
roots=[];h=P.home()
|
||||
if S!='windows':roots += [('wsl',h/'.agents/skills'),('wsl',h/'.codex/skills'),('wsl',P('/root/.agents/skills')),('wsl',P('/root/.codex/skills'))]
|
||||
if S!='wsl' and isw():
|
||||
try:users=list(P('/mnt/c/Users').iterdir())
|
||||
except Exception:users=[]
|
||||
for u in users:
|
||||
if u.is_dir() and u.name.lower() not in {'all users','default','default user','public'}:roots += [('windows',u/'.agents/skills'),('windows',u/'.codex/skills')]
|
||||
seen=set();rr=[];ss=[]
|
||||
for sc,r in roots:
|
||||
k=(sc,str(r))
|
||||
if k in seen:continue
|
||||
seen.add(k);a,b=scan(sc,r);rr.append(a);ss+=b
|
||||
ss.sort(key=lambda x:(0 if x['scope']=='wsl' else 1,x['name'].lower(),x['path']));tb=len(ss)
|
||||
if L>0:ss=ss[:L]
|
||||
c={'total':len(ss),'totalBeforeLimit':tb,'wsl':sum(1 for x in ss if x['scope']=='wsl'),'windows':sum(1 for x in ss if x['scope']=='windows')}
|
||||
print(json.dumps({'ok':True,'command':'unidesk ssh skills','node':{'hostname':socket.gethostname(),'user':getpass.getuser(),'home':str(P.home()),'platform':platform.platform(),'isWsl':isw()},'counts':c,'roots':rr,'skills':ss},ensure_ascii=False,indent=2))`;
|
||||
|
||||
function remoteFrontendSkillDiscoverCommand(args: string[]): string {
|
||||
let scope = "all";
|
||||
let limit = 0;
|
||||
let maxDepth = 4;
|
||||
const start = args[0] === "skill" ? 2 : 1;
|
||||
for (let index = start; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
const next = args[index + 1];
|
||||
if (arg === "--scope") {
|
||||
if (next !== "all" && next !== "wsl" && next !== "windows") throw new Error("ssh skills --scope must be one of: all, wsl, windows");
|
||||
scope = next;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--limit") {
|
||||
const value = Number(next);
|
||||
if (!Number.isInteger(value) || value < 0) throw new Error("ssh skills --limit must be >= 0");
|
||||
limit = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--max-depth") {
|
||||
const value = Number(next);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("ssh skills --max-depth must be positive");
|
||||
maxDepth = value;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`remote frontend ssh skills does not support option: ${arg}`);
|
||||
}
|
||||
return `S=${shellQuote(scope)} L=${shellQuote(String(limit))} D=${shellQuote(String(maxDepth))} python3 -c ${shellQuote(compactSkillDiscoverPython)}`;
|
||||
}
|
||||
|
||||
function dispatchPayload(args: string[], command: DebugDispatchCommand): Record<string, unknown> {
|
||||
const explicit = jsonOption(args, "--payload-json") ?? {};
|
||||
if (command === "provider.upgrade") {
|
||||
@@ -378,6 +468,22 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
|
||||
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | proxy <id> <path>");
|
||||
}
|
||||
|
||||
async function remoteCodexQueue(session: FrontendSession, args: string[]): Promise<unknown> {
|
||||
const action = args[1] ?? "task";
|
||||
if (action !== "task" && action !== "summary" && action !== "show" && action !== "output") {
|
||||
throw new Error("remote codex command must be: codex task <taskId> or codex output <taskId>");
|
||||
}
|
||||
const taskId = args[2];
|
||||
if (taskId === undefined || taskId.length === 0) throw new Error(`codex ${action} requires task id`);
|
||||
const fetcher = (path: string): Promise<FetchJsonResult> => frontendJson(session, path, undefined, 24_000);
|
||||
return {
|
||||
transport: "frontend",
|
||||
result: action === "output"
|
||||
? await codexOutputQueryAsync(taskId, args.slice(3), fetcher)
|
||||
: await codexTaskQueryAsync(taskId, args.slice(3), fetcher),
|
||||
};
|
||||
}
|
||||
|
||||
async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise<number> {
|
||||
if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
|
||||
const parsed = parseSshArgs(args);
|
||||
@@ -389,12 +495,17 @@ async function runRemoteSshOverFrontend(session: FrontendSession, providerId: st
|
||||
process.stderr.write("remote frontend transport supports ssh remote commands only; pass a command such as: ssh D601 hostname\n");
|
||||
return 255;
|
||||
}
|
||||
if (args[0] === "glob") {
|
||||
process.stderr.write("remote frontend transport does not support the ssh glob helper because host.ssh exec has a short command-length limit; run it on the main server CLI instead\n");
|
||||
return 255;
|
||||
}
|
||||
const remoteCommand = isSshSkillDiscoveryArgs(args) ? remoteFrontendSkillDiscoverCommand(args) : parsed.remoteCommand;
|
||||
const dispatch = await frontendJson(session, "/api/dispatch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
providerId,
|
||||
command: "host.ssh",
|
||||
payload: { source: "cli-remote-ssh", mode: "exec", command: parsed.remoteCommand, timeoutMs: 15000 },
|
||||
payload: { source: "cli-remote-ssh", mode: "exec", command: remoteCommand, timeoutMs: isSshSkillDiscoveryArgs(args) ? 30000 : 15000 },
|
||||
}),
|
||||
});
|
||||
const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
|
||||
@@ -427,7 +538,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, {
|
||||
transport: "frontend",
|
||||
baseUrl: session.baseUrl,
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "codex task <taskId>"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
@@ -447,6 +558,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, await remoteMicroservice(session, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "codex") {
|
||||
emitRemoteJson(name, await remoteCodexQueue(session, args));
|
||||
return 0;
|
||||
}
|
||||
if (top === "ssh") {
|
||||
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
|
||||
}
|
||||
|
||||
+264
-2
@@ -212,6 +212,256 @@ def main():
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
`;
|
||||
|
||||
const remoteSkillDiscoverSource = String.raw`#!/usr/bin/env python3
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKIP_PARTS = {"node_modules", ".git", ".state", "logs", "references", "__pycache__"}
|
||||
|
||||
|
||||
def is_wsl():
|
||||
try:
|
||||
release = Path("/proc/sys/kernel/osrelease").read_text(errors="ignore").lower()
|
||||
except Exception:
|
||||
release = ""
|
||||
return "microsoft" in release or "wsl" in release or "WSL_INTEROP" in os.environ
|
||||
|
||||
|
||||
def to_windows_path(path):
|
||||
text = str(path)
|
||||
if text.startswith("/mnt/") and len(text) >= 7 and text[5].isalpha() and text[6] == "/":
|
||||
drive = text[5].upper()
|
||||
rest = text[7:].replace("/", "\\")
|
||||
return drive + ":\\" + rest
|
||||
return None
|
||||
|
||||
|
||||
def read_bounded(path, limit=16384):
|
||||
try:
|
||||
data = path.read_bytes()[:limit]
|
||||
return data.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def frontmatter_value(line):
|
||||
if ":" not in line:
|
||||
return None
|
||||
key, value = line.split(":", 1)
|
||||
return key.strip().lower(), value.strip().strip("\"'")
|
||||
|
||||
|
||||
def parse_skill_metadata(skill_md):
|
||||
text = read_bounded(skill_md)
|
||||
name = skill_md.parent.name
|
||||
description = ""
|
||||
lines = text.splitlines()
|
||||
if lines and lines[0].strip() == "---":
|
||||
for line in lines[1:]:
|
||||
if line.strip() == "---":
|
||||
break
|
||||
item = frontmatter_value(line)
|
||||
if item is None:
|
||||
continue
|
||||
key, value = item
|
||||
if key == "name" and value:
|
||||
name = value
|
||||
if key == "description" and value:
|
||||
description = value
|
||||
if not description:
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("---") and not stripped.startswith("#"):
|
||||
description = stripped
|
||||
break
|
||||
return name, description
|
||||
|
||||
|
||||
def iter_skill_files(root, max_depth):
|
||||
try:
|
||||
iterator = root.rglob("SKILL.md")
|
||||
for skill_md in iterator:
|
||||
try:
|
||||
rel = skill_md.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
directory_parts = rel.parts[:-1]
|
||||
if len(directory_parts) == 0 or len(directory_parts) > max_depth:
|
||||
continue
|
||||
if any(part in SKIP_PARTS for part in directory_parts):
|
||||
continue
|
||||
yield skill_md
|
||||
except Exception as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def unique_paths(paths):
|
||||
seen = set()
|
||||
output = []
|
||||
for raw in paths:
|
||||
path = Path(raw).expanduser()
|
||||
key = str(path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
output.append(path)
|
||||
return output
|
||||
|
||||
|
||||
def default_wsl_roots():
|
||||
home = Path.home()
|
||||
roots = [home / ".agents" / "skills", home / ".codex" / "skills"]
|
||||
for raw in ("/root/.agents/skills", "/root/.codex/skills"):
|
||||
path = Path(raw)
|
||||
if str(path) not in {str(item) for item in roots}:
|
||||
roots.append(path)
|
||||
return roots
|
||||
|
||||
|
||||
def default_windows_roots():
|
||||
if not is_wsl():
|
||||
return []
|
||||
users = Path("/mnt/c/Users")
|
||||
roots = []
|
||||
try:
|
||||
children = list(users.iterdir()) if users.exists() else []
|
||||
except Exception:
|
||||
children = []
|
||||
for child in children:
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
lower = child.name.lower()
|
||||
if lower in {"all users", "default", "default user", "public"}:
|
||||
continue
|
||||
roots.append(child / ".agents" / "skills")
|
||||
roots.append(child / ".codex" / "skills")
|
||||
return roots
|
||||
|
||||
|
||||
def scan_root(scope, root, max_depth):
|
||||
try:
|
||||
root_exists = root.exists()
|
||||
root_error = None
|
||||
except Exception as exc:
|
||||
root_exists = False
|
||||
root_error = str(exc)
|
||||
record = {
|
||||
"scope": scope,
|
||||
"path": str(root),
|
||||
"windowsPath": to_windows_path(root),
|
||||
"exists": root_exists,
|
||||
"skillCount": 0,
|
||||
"error": root_error,
|
||||
}
|
||||
skills = []
|
||||
if not record["exists"]:
|
||||
return record, skills
|
||||
try:
|
||||
for skill_md in iter_skill_files(root, max_depth):
|
||||
name, description = parse_skill_metadata(skill_md)
|
||||
skill = {
|
||||
"scope": scope,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"path": str(skill_md.parent),
|
||||
"skillMd": str(skill_md),
|
||||
"windowsPath": to_windows_path(skill_md.parent),
|
||||
"root": str(root),
|
||||
}
|
||||
skills.append(skill)
|
||||
except Exception as exc:
|
||||
record["error"] = str(exc)
|
||||
record["skillCount"] = len(skills)
|
||||
return record, skills
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="discover WSL/Linux and Windows skill directories from a UniDesk ssh passthrough session")
|
||||
parser.add_argument("--scope", choices=["all", "wsl", "windows"], default="all", help="which skill roots to scan")
|
||||
parser.add_argument("--max-depth", type=int, default=4, help="maximum directory depth below each skill root")
|
||||
parser.add_argument("--limit", type=int, default=0, help="maximum skill rows to return; 0 means unlimited")
|
||||
parser.add_argument("--root", action="append", default=[], help="extra WSL/Linux skill root")
|
||||
parser.add_argument("--windows-root", action="append", default=[], help="extra Windows skill root, expressed as /mnt/<drive>/...")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.max_depth <= 0:
|
||||
print(json.dumps({"ok": False, "error": "--max-depth must be positive"}, ensure_ascii=False))
|
||||
return 2
|
||||
if args.limit < 0:
|
||||
print(json.dumps({"ok": False, "error": "--limit must be >= 0"}, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
roots = []
|
||||
if args.scope in ("all", "wsl"):
|
||||
roots.extend(("wsl", path) for path in default_wsl_roots())
|
||||
roots.extend(("wsl", Path(raw).expanduser()) for raw in args.root)
|
||||
if args.scope in ("all", "windows"):
|
||||
roots.extend(("windows", path) for path in default_windows_roots())
|
||||
roots.extend(("windows", Path(raw).expanduser()) for raw in args.windows_root)
|
||||
|
||||
seen = set()
|
||||
unique = []
|
||||
for scope, path in roots:
|
||||
key = (scope, str(path))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append((scope, path))
|
||||
|
||||
root_records = []
|
||||
skills = []
|
||||
for scope, root in unique:
|
||||
record, found = scan_root(scope, root, args.max_depth)
|
||||
root_records.append(record)
|
||||
skills.extend(found)
|
||||
|
||||
scope_order = {"wsl": 0, "windows": 1}
|
||||
skills.sort(key=lambda item: (scope_order.get(str(item["scope"]), 9), str(item["name"]).lower(), str(item["path"])))
|
||||
total_before_limit = len(skills)
|
||||
if args.limit > 0:
|
||||
skills = skills[:args.limit]
|
||||
|
||||
counts = {"total": len(skills), "totalBeforeLimit": total_before_limit, "wsl": 0, "windows": 0}
|
||||
for skill in skills:
|
||||
scope = str(skill["scope"])
|
||||
if scope in counts:
|
||||
counts[scope] += 1
|
||||
|
||||
payload = {
|
||||
"ok": True,
|
||||
"command": "unidesk ssh skills",
|
||||
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"node": {
|
||||
"hostname": socket.gethostname(),
|
||||
"user": getpass.getuser(),
|
||||
"home": str(Path.home()),
|
||||
"platform": platform.platform(),
|
||||
"isWsl": is_wsl(),
|
||||
"python": sys.version.split()[0],
|
||||
},
|
||||
"counts": counts,
|
||||
"roots": root_records,
|
||||
"skills": skills,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
`;
|
||||
@@ -220,8 +470,17 @@ const sshOptionsWithValue = new Set([
|
||||
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
|
||||
]);
|
||||
|
||||
export function isSshSkillDiscoveryArgs(args: string[]): boolean {
|
||||
const subcommand = args[0] ?? "";
|
||||
return subcommand === "skills" || subcommand === "skill-discover" || subcommand === "discover-skills" || (subcommand === "skill" && args[1] === "discover");
|
||||
}
|
||||
|
||||
export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
const subcommand = args[0] ?? "";
|
||||
if (isSshSkillDiscoveryArgs(args)) {
|
||||
const toolArgs = subcommand === "skill" ? ["skill-discover", ...args.slice(2)] : ["skill-discover", ...args.slice(1)];
|
||||
return { remoteCommand: shellArgv(toolArgs), requiresStdin: false };
|
||||
}
|
||||
if (subcommand === "apply-patch" || subcommand === "patch") {
|
||||
const toolArgs = ["apply_patch", ...args.slice(1)];
|
||||
return { remoteCommand: shellArgv(toolArgs), requiresStdin: true };
|
||||
@@ -379,18 +638,21 @@ function buildPythonStdinCommand(args: string[]): string {
|
||||
function remoteToolBootstrapCommand(): string {
|
||||
const encodedApplyPatch = Buffer.from(remoteApplyPatchSource, "utf8").toString("base64");
|
||||
const encodedGlob = Buffer.from(remoteGlobSource, "utf8").toString("base64");
|
||||
const encodedSkillDiscover = Buffer.from(remoteSkillDiscoverSource, "utf8").toString("base64");
|
||||
return [
|
||||
"UNIDESK_SSH_TOOL_DIR=/tmp/unidesk-ssh-tools",
|
||||
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
||||
`printf %s ${shellQuote(encodedApplyPatch)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/apply_patch"`,
|
||||
`printf %s ${shellQuote(encodedGlob)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/glob"`,
|
||||
`printf %s ${shellQuote(encodedSkillDiscover)} | base64 -d > "$UNIDESK_SSH_TOOL_DIR/skill-discover"`,
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/glob"',
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/skill-discover"',
|
||||
'export PATH="$UNIDESK_SSH_TOOL_DIR:$PATH"',
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function wrapRemoteCommand(command: string | null): string {
|
||||
export function wrapSshRemoteCommand(command: string | null): string {
|
||||
const bootstrap = remoteToolBootstrapCommand();
|
||||
if (command === null) return `${bootstrap}; exec "\${SHELL:-/bin/bash}" -l`;
|
||||
return `${bootstrap}; stty -echo 2>/dev/null || true; ${command}`;
|
||||
@@ -533,7 +795,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
||||
const payload = {
|
||||
providerId,
|
||||
command: wrapRemoteCommand(parsed.remoteCommand),
|
||||
command: wrapSshRemoteCommand(parsed.remoteCommand),
|
||||
tty: parsed.remoteCommand === null,
|
||||
stdinEotOnEnd: parsed.remoteCommand !== null,
|
||||
openTimeoutMs,
|
||||
|
||||
Reference in New Issue
Block a user