Files
pikasTech-unidesk/scripts/src/debug.ts
T
2026-07-05 18:08:17 +00:00

603 lines
26 KiB
TypeScript

import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { runSshCommandCapture } from "./ssh";
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "microservice.http", "echo"] as const;
export type DebugDispatchCommand = typeof dispatchCommands[number];
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
return dispatchCommands.includes(value as DebugDispatchCommand);
}
async function readJson(url: string, init?: RequestInit): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
const text = await res.text();
return { ok: res.ok, status: res.status, body: text.length > 0 ? JSON.parse(text) : null };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
clearTimeout(timer);
}
}
function coreFetchCommand(path: string, init?: { method?: string; body?: unknown }): string[] {
const method = init?.method ?? "GET";
const url = `http://127.0.0.1:8080${path}`;
const body = init?.body === undefined ? "" : JSON.stringify(init.body);
const script = [
"set -eu",
"if command -v backend-core >/dev/null 2>&1; then",
` exec backend-core --fetch-json ${shellQuote(url)} --method ${shellQuote(method)}${body.length > 0 ? ` --body-json ${shellQuote(body)}` : ""}`,
"fi",
`url=${shellQuote(url)}`,
`method=${shellQuote(method)}`,
`body=${shellQuote(body)}`,
"export url method body",
"bun -e 'const url=process.env.url; const method=process.env.method; const body=process.env.body; fetch(url,{method,body:body?body:undefined,headers:body?{\"content-type\":\"application/json\"}:undefined}).then(async r=>{const text=await r.text(); console.log(JSON.stringify({ok:r.ok,status:r.status,body:text?JSON.parse(text):null})); process.exit(r.ok?0:1);}).catch(e=>{console.error(e); process.exit(1);})'",
].join("\n");
return ["docker", "exec", "unidesk-backend-core", "sh", "-lc", script];
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown {
const command = coreFetchCommand(path, init);
const result = runCommand(command, repoRoot);
if (result.exitCode !== 0) {
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
try {
return JSON.parse(result.stdout.trim()) as unknown;
} catch {
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
}
function coreDockerStatusSummary(): unknown {
const result = runCommand(coreFetchCommand("/api/nodes/docker-status"), repoRoot);
if (result.exitCode !== 0) {
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
try {
const parsed = JSON.parse(result.stdout.trim()) as { ok?: boolean; status?: number; body?: { dockerStatuses?: Array<Record<string, unknown>> } };
const dockerStatuses = (parsed.body?.dockerStatuses || []).map((item) => {
const status = (item.dockerStatus || {}) as Record<string, unknown>;
return {
providerId: item.providerId,
name: item.name,
nodeStatus: item.nodeStatus,
updatedAt: item.updatedAt,
dockerStatus: {
ok: status.ok,
socketPresent: status.socketPresent,
collectedAt: status.collectedAt,
counts: status.counts,
daemon: status.daemon,
containersPreview: Array.isArray(status.containers) ? status.containers.slice(0, 8) : [],
},
};
});
return { ok: parsed.ok, status: parsed.status, body: { ok: parsed.body !== undefined, dockerStatuses } };
} catch {
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
}
function coreSystemStatusSummary(): unknown {
const result = runCommand(coreFetchCommand("/api/nodes/system-status?limit=24"), repoRoot);
if (result.exitCode !== 0) {
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
try {
const parsed = JSON.parse(result.stdout.trim()) as { ok?: boolean; status?: number; body?: { systemStatuses?: Array<Record<string, unknown>> } };
const systemStatuses = (parsed.body?.systemStatuses || []).map((item) => {
const current = (item.current || {}) as Record<string, unknown>;
const history = Array.isArray(item.history) ? item.history : [];
return {
providerId: item.providerId,
name: item.name,
nodeStatus: item.nodeStatus,
updatedAt: item.updatedAt,
current: item.current ? { ok: current.ok, collectedAt: current.collectedAt, cpu: current.cpu, memory: current.memory, disk: current.disk } : null,
historyPreview: history.slice(-8),
historyCount: history.length,
};
});
return { ok: parsed.ok, status: parsed.status, body: { ok: parsed.body !== undefined, systemStatuses } };
} catch {
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
}
}
export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
return {
coreInternal: await coreInternalFetch("/health"),
overviewInternal: await coreInternalFetch("/api/overview"),
nodesInternal: await coreInternalFetch("/api/nodes"),
systemStatusInternal: coreSystemStatusSummary(),
dockerStatusInternal: coreDockerStatusSummary(),
frontendPublic: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`),
providerIngressPublic: await readJson(`http://127.0.0.1:${config.network.providerIngress.port}/health`),
publicExposureBoundary: {
coreHostPort: { port: config.network.core.port, expected: "not-exposed" },
databaseHostPort: { port: config.network.database.port, expected: "restricted-to-code-queue-provider" },
oaEventFlowHostPort: { port: 4255, expected: "restricted-to-code-queue-provider" },
},
};
}
function recordValue(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function arrayValue(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function stringValue(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function numberValue(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function rounded(value: number | null): number | null {
return value === null ? null : Math.round(value * 100) / 100;
}
function providerHostPressureSummary(providerId: string): unknown {
const response = coreInternalFetch("/api/nodes/system-status?limit=24");
const body = recordValue(recordValue(response).body);
const systemStatuses = arrayValue(body.systemStatuses);
const item = systemStatuses
.map((entry) => recordValue(entry))
.find((entry) => entry.providerId === providerId) ?? null;
if (item === null) {
return {
ok: false,
degradedReason: "system-status-not-found",
systemStatusFetch: response,
};
}
const current = recordValue(item.current);
if (Object.keys(current).length === 0) {
return {
ok: false,
providerId,
degradedReason: "system-status-current-missing",
updatedAt: item.updatedAt ?? null,
nodeStatus: item.nodeStatus ?? null,
classification: "host-pressure-snapshot-unavailable",
};
}
const cpu = recordValue(current.cpu);
const memory = recordValue(current.memory);
const disk = recordValue(current.disk);
const cores = numberValue(cpu.cores);
const load1 = numberValue(cpu.load1);
const load5 = numberValue(cpu.load5);
const load15 = numberValue(cpu.load15);
const load1PerCore = cores !== null && cores > 0 && load1 !== null ? load1 / cores : null;
const memoryPercent = numberValue(memory.percent);
const memoryAvailableBytes = numberValue(memory.availableBytes);
const swapTotalBytes = numberValue(memory.swapTotalBytes);
const diskPercent = numberValue(disk.percent);
const signals: string[] = [];
if (load1PerCore !== null && load1PerCore >= 4) signals.push("load1-per-core>=4");
if (memoryPercent !== null && memoryPercent >= 90) signals.push("memory-percent>=90");
if (memoryAvailableBytes !== null && memoryAvailableBytes < 512 * 1024 * 1024) signals.push("memory-available<512MiB");
if (swapTotalBytes === 0) signals.push("swap-disabled");
if (diskPercent !== null && diskPercent >= 90) signals.push("disk-percent>=90");
return {
ok: current.ok ?? null,
providerId,
updatedAt: item.updatedAt ?? null,
collectedAt: current.collectedAt ?? null,
cpu: {
cores,
load1,
load5,
load15,
load1PerCore: rounded(load1PerCore),
},
memory: {
percent: memoryPercent,
availableBytes: memoryAvailableBytes,
swapTotalBytes,
},
disk: {
percent: diskPercent,
availableBytes: numberValue(disk.availableBytes),
mount: disk.mount ?? null,
},
signals,
classification: signals.length > 0 ? "host-pressure-signals-present" : "host-pressure-signals-not-observed",
};
}
export async function debugSshPool(_config: UniDeskConfig, providerId: string): Promise<unknown> {
const nodesResponse = await coreInternalFetch("/api/nodes");
const body = recordValue(recordValue(nodesResponse).body);
const nodes = arrayValue(body.nodes);
const node = nodes
.map((item) => recordValue(item))
.find((item) => item.providerId === providerId) ?? null;
if (node === null) {
return {
ok: false,
providerId,
degradedReason: "provider-not-found",
nodesFetch: nodesResponse,
next: { fullHealth: "bun scripts/cli.ts debug health" },
};
}
const labels = recordValue(node.labels);
const nodeStatus = stringValue(node.status);
const pool = {
transport: stringValue(labels.providerGatewaySshDataTransport),
host: stringValue(labels.providerGatewaySshDataHost),
port: labels.providerGatewaySshDataPort ?? null,
desired: labels.providerGatewaySshDataPoolDesired ?? null,
total: labels.providerGatewaySshDataPoolTotal ?? null,
ready: labels.providerGatewaySshDataPoolReady ?? null,
claimed: labels.providerGatewaySshDataPoolClaimed ?? null,
connecting: labels.providerGatewaySshDataPoolConnecting ?? null,
lastError: labels.providerGatewaySshDataPoolLastError ?? null,
};
const ready = Number(pool.ready ?? 0);
const claimed = Number(pool.claimed ?? 0);
const desired = Number(pool.desired ?? 0);
const providerOnline = nodeStatus === "online";
const poolLabelReady = pool.transport === "tcp-pool" && Number.isFinite(ready) && ready > 0;
const executionPathReady = providerOnline && poolLabelReady;
return {
ok: executionPathReady,
providerId,
providerOnline,
poolLabelReady,
executionPathReady,
node: {
providerId: node.providerId,
name: node.name,
status: nodeStatus,
lastHeartbeat: node.lastHeartbeat ?? null,
updatedAt: node.updatedAt ?? null,
},
pool,
hostPressure: providerHostPressureSummary(providerId),
classification: !providerOnline
? (nodeStatus === "offline" ? "provider-offline" : "provider-not-online")
: poolLabelReady
? "ssh-tcp-pool-ready"
: pool.transport !== "tcp-pool"
? "provider-gateway-upgrade-required"
: desired > 0 && claimed >= desired
? "provider-data-pool-exhausted"
: "provider-data-pool-not-ready",
next: {
smoke: `trans ${providerId} argv true`,
fullHealth: "bun scripts/cli.ts debug health",
gcDegradedSnapshot: `bun scripts/cli.ts gc remote ${providerId} snapshot --no-save`,
},
note: "Pool labels are provider-reported channel state; use the smoke command or gc degraded output to verify execution path.",
};
}
export async function debugEgressProxy(_config: UniDeskConfig, providerId: string): Promise<unknown> {
const nodesResponse = await coreInternalFetch("/api/nodes");
const body = recordValue(recordValue(nodesResponse).body);
const nodes = arrayValue(body.nodes);
const node = nodes
.map((item) => recordValue(item))
.find((item) => item.providerId === providerId) ?? null;
if (node === null) {
return {
ok: false,
providerId,
degradedReason: "provider-not-found",
nodesFetch: nodesResponse,
next: { fullHealth: "bun scripts/cli.ts debug health" },
};
}
const labels = recordValue(node.labels);
const active = Number(labels.providerGatewayEgressProxyActiveTunnels ?? 0);
const pending = Number(labels.providerGatewayEgressProxyPendingTunnels ?? 0);
const opened = Number(labels.providerGatewayEgressProxyOpenedTunnels ?? 0);
const stale = Number(labels.providerGatewayEgressProxyStaleTunnels ?? 0);
const enabled = labels.providerGatewayEgressProxy === true;
const connected = labels.providerGatewayEgressProxyConnected === true;
const sshReady = Number(labels.providerGatewaySshDataPoolReady ?? 0);
const activeTunnelDetails = arrayValue(labels.providerGatewayEgressProxyActiveTunnelDetails);
const recentClosedTunnels = arrayValue(labels.providerGatewayEgressProxyRecentClosedTunnels);
const ok = enabled && connected && stale === 0;
return {
ok,
providerId,
node: {
providerId: node.providerId,
name: node.name,
status: node.status,
lastHeartbeat: node.lastHeartbeat ?? null,
updatedAt: node.updatedAt ?? null,
},
egressProxy: {
enabled,
connected,
port: labels.providerGatewayEgressProxyPort ?? null,
activeTunnels: Number.isFinite(active) ? active : 0,
pendingTunnels: Number.isFinite(pending) ? pending : 0,
openedTunnels: Number.isFinite(opened) ? opened : 0,
staleTunnels: Number.isFinite(stale) ? stale : 0,
oldestTunnelAgeMs: labels.providerGatewayEgressProxyOldestTunnelAgeMs ?? null,
policy: {
openTimeoutMs: labels.providerGatewayEgressProxyOpenTimeoutMs ?? null,
idleTimeoutMs: labels.providerGatewayEgressProxyIdleTimeoutMs ?? null,
maxTunnelAgeMs: labels.providerGatewayEgressProxyMaxTunnelAgeMs ?? null,
staleTunnelIdleMs: labels.providerGatewayEgressProxyStaleTunnelIdleMs ?? null,
maxPendingBytes: labels.providerGatewayEgressProxyMaxPendingBytes ?? null,
},
activeTunnelDetails,
activeTunnelDetailsCount: activeTunnelDetails.length,
recentClosedTunnels,
recentClosedTunnelCount: recentClosedTunnels.length,
},
controlPlaneSeparation: {
sshDataTransport: stringValue(labels.providerGatewaySshDataTransport),
sshDataPoolReady: Number.isFinite(sshReady) ? sshReady : 0,
sshDataPoolDesired: labels.providerGatewaySshDataPoolDesired ?? null,
sshDataPoolClaimed: labels.providerGatewaySshDataPoolClaimed ?? null,
note: "SSH/control readiness is reported separately from egress download tunnel activity.",
},
classification: !enabled
? "egress-proxy-disabled"
: !connected
? "egress-proxy-core-channel-disconnected"
: stale > 0
? "egress-tunnel-stale"
: active > 0
? "egress-tunnel-active"
: "egress-proxy-ready",
outputPolicy: {
redaction: "activeTunnelDetails expose target host/port and path/query-key summary only; URL credentials, headers, tokens and query values are not emitted.",
boundedRecentClosedTunnels: true,
},
next: {
smoke: `trans ${providerId} argv true`,
fullHealth: "bun scripts/cli.ts debug health",
},
};
}
const providerRootfsMarker = "__UNIDESK_PROVIDER_ROOTFS__";
function safeDockerName(value: string): string {
return value.replace(/[^a-zA-Z0-9_.-]/gu, "-").slice(0, 80);
}
function providerGatewayContainerName(providerId: string): string {
return `unidesk-provider-gateway-${safeDockerName(providerId)}`.toLowerCase();
}
function providerRootfsDiagnosticScript(providerId: string): string {
const container = providerGatewayContainerName(providerId);
return [
"set +e",
`MARK=${shellQuote(providerRootfsMarker)}`,
`container=${shellQuote(container)}`,
`provider_id=${shellQuote(providerId)}`,
'emit() { printf "%s%s=%s\\n" "$MARK" "$1" "$2"; }',
'emit_b64() { key="$1"; value="$2"; encoded=$(printf "%s" "$value" | base64 | tr -d "\\n"); emit "$key" "$encoded"; }',
'emit provider_id "$provider_id"',
'emit container_name "$container"',
"inspect_output=$(docker inspect \"$container\" --format 'id={{.Id}}\nname={{.Name}}\nimageId={{.Image}}\nimageName={{.Config.Image}}\nstatus={{.State.Status}}\nrunning={{.State.Running}}\nrestartPolicy={{.HostConfig.RestartPolicy.Name}}\npidMode={{.HostConfig.PidMode}}' 2>&1)",
"inspect_code=$?",
'emit inspect_exit_code "$inspect_code"',
'emit_b64 inspect_output "$inspect_output"',
"smoke_output=$(docker exec \"$container\" /bin/sh -lc 'set -eu\n test -x /bin/sh\n test -x /usr/bin/ssh\n /usr/bin/ssh -V >/tmp/unidesk-provider-ssh-version 2>&1 || { cat /tmp/unidesk-provider-ssh-version >&2; exit 41; }\n command -v docker >/dev/null\n env -u DOCKER_HOST docker version --format \"{{.Client.Version}}\" >/dev/null\n test -e /lib/libz.so.1 -o -e /usr/lib/libz.so.1\n command -v bun >/dev/null\n printf \"rootfs-smoke-ok\\n\"' 2>&1)",
"smoke_code=$?",
'emit rootfs_smoke_exit_code "$smoke_code"',
'emit_b64 rootfs_smoke_output "$smoke_output"',
'system_df_output=$(docker system df 2>&1)',
"system_df_code=$?",
'emit docker_system_df_exit_code "$system_df_code"',
'emit_b64 docker_system_df_output "$system_df_output"',
"snapshot_summary=$(printf '%s\\n' \"$system_df_output\" | grep -Ei 'snapshot|lowerdir|no such file|not found|missing|lstat|failed' | tail -20)",
'emit_b64 snapshot_missing_summary "$snapshot_summary"',
'logs_output=$(docker logs --since 24h --tail 3000 "$container" 2>&1)',
"logs_code=$?",
'emit docker_logs_exit_code "$logs_code"',
"last_started=$(printf '%s\\n' \"$logs_output\" | grep 'host_ssh_session_started' | tail -1)",
"last_success=$(printf '%s\\n' \"$logs_output\" | grep 'host_ssh_session_completed' | grep '\"exitCode\":0' | tail -1)",
"last_failure=$(printf '%s\\n' \"$logs_output\" | grep -E 'host_ssh_session_completed|host_ssh_session_failed|host_ssh_session_error' | grep -Ev '\"exitCode\":0' | tail -1)",
'emit_b64 last_host_ssh_started "$last_started"',
'emit_b64 last_host_ssh_success "$last_success"',
'emit_b64 last_host_ssh_failure "$last_failure"',
"exit 0",
].join("\n");
}
function parseProviderRootfsFields(stdout: string): Record<string, string> {
const fields: Record<string, string> = {};
for (const line of stdout.split(/\r?\n/u)) {
if (!line.startsWith(providerRootfsMarker)) continue;
const payload = line.slice(providerRootfsMarker.length);
const index = payload.indexOf("=");
if (index <= 0) continue;
fields[payload.slice(0, index)] = payload.slice(index + 1);
}
return fields;
}
function b64Field(fields: Record<string, string>, key: string): string {
const raw = fields[key] ?? "";
if (raw.length === 0) return "";
try {
return Buffer.from(raw, "base64").toString("utf8");
} catch {
return "";
}
}
function numberField(fields: Record<string, string>, key: string): number | null {
const value = Number(fields[key]);
return Number.isFinite(value) ? value : null;
}
function parseKeyValueBlock(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of text.split(/\r?\n/u)) {
const index = line.indexOf("=");
if (index <= 0) continue;
values[line.slice(0, index)] = line.slice(index + 1);
}
return values;
}
function trimForDiagnostic(text: string, maxChars = 1200): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return trimmed.slice(trimmed.length - maxChars);
}
function extractIsoTimeFromLogLine(line: string): string | null {
const trimmed = line.trim();
if (trimmed.length === 0) return null;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
for (const key of ["time", "timestamp", "at"]) {
const value = parsed[key];
if (typeof value === "string" && value.length > 0) return value;
}
} catch {
// Fall through to regex extraction for non-JSON log formats.
}
return trimmed.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/u)?.[0] ?? null;
}
export async function debugProviderRootfs(config: UniDeskConfig, providerId: string): Promise<unknown> {
const script = providerRootfsDiagnosticScript(providerId);
const capture = await runSshCommandCapture(config, providerId, ["bash"], script);
const fields = parseProviderRootfsFields(capture.stdout);
const inspectOutput = b64Field(fields, "inspect_output");
const inspect = parseKeyValueBlock(inspectOutput);
const smokeOutput = b64Field(fields, "rootfs_smoke_output");
const systemDfOutput = b64Field(fields, "docker_system_df_output");
const snapshotSummary = b64Field(fields, "snapshot_missing_summary");
const lastStarted = b64Field(fields, "last_host_ssh_started");
const lastSuccess = b64Field(fields, "last_host_ssh_success");
const lastFailure = b64Field(fields, "last_host_ssh_failure");
const inspectExitCode = numberField(fields, "inspect_exit_code");
const smokeExitCode = numberField(fields, "rootfs_smoke_exit_code");
const systemDfExitCode = numberField(fields, "docker_system_df_exit_code");
const dockerLogsExitCode = numberField(fields, "docker_logs_exit_code");
const transOk = capture.exitCode === 0;
const ok = transOk && inspectExitCode === 0 && smokeExitCode === 0 && systemDfExitCode === 0;
return {
ok,
providerId,
containerName: fields.container_name ?? providerGatewayContainerName(providerId),
trans: {
ok: transOk,
exitCode: capture.exitCode,
stderrTail: trimForDiagnostic(capture.stderr, 1600),
},
container: {
inspectExitCode,
id: inspect.id ?? null,
name: inspect.name?.replace(/^\//u, "") ?? null,
imageId: inspect.imageId ?? null,
imageName: inspect.imageName ?? null,
status: inspect.status ?? null,
running: inspect.running ?? null,
restartPolicy: inspect.restartPolicy ?? null,
pidMode: inspect.pidMode ?? null,
inspectError: inspectExitCode === 0 ? null : trimForDiagnostic(inspectOutput),
},
rootfsSmoke: {
ok: smokeExitCode === 0,
exitCode: smokeExitCode,
outputTail: trimForDiagnostic(smokeOutput),
checks: ["/bin/sh", "/usr/bin/ssh -V", "libz.so.1", "docker client", "bun"],
},
dockerSystemDf: {
ok: systemDfExitCode === 0,
exitCode: systemDfExitCode,
outputTail: trimForDiagnostic(systemDfOutput),
snapshotMissingSummary: trimForDiagnostic(snapshotSummary),
},
hostSsh: {
dockerLogsExitCode,
lastStarted: {
at: extractIsoTimeFromLogLine(lastStarted),
line: trimForDiagnostic(lastStarted),
},
lastSuccess: {
at: extractIsoTimeFromLogLine(lastSuccess),
line: trimForDiagnostic(lastSuccess),
},
lastFailure: {
at: extractIsoTimeFromLogLine(lastFailure),
line: trimForDiagnostic(lastFailure),
},
note: "Success/failure rows require provider-gateway versions that log host_ssh_session_completed or host_ssh_session_failed; older containers may only expose lastStarted/error.",
},
classification: !transOk
? "trans-channel-unavailable"
: inspectExitCode !== 0
? "provider-container-missing"
: smokeExitCode !== 0
? "provider-rootfs-smoke-failed"
: systemDfExitCode !== 0
? "docker-system-df-failed"
: "provider-rootfs-healthy",
next: {
sshPool: `bun scripts/cli.ts debug ssh-pool ${providerId}`,
providerUpgrade: `bun scripts/cli.ts debug dispatch ${providerId} provider.upgrade --mode schedule --wait-ms 300000`,
},
};
}
async function waitForTask(taskId: string, timeoutMs: number): Promise<unknown> {
const started = Date.now();
let latest: unknown = null;
while (Date.now() - started < timeoutMs) {
latest = coreInternalFetch("/api/tasks?limit=100");
const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? [];
const task = tasks.find((item) => item.id === taskId);
if (task?.status === "succeeded" || task?.status === "failed") return { ok: true, task };
await Bun.sleep(500);
}
return { ok: false, timeoutMs, latest };
}
export async function debugTask(_config: UniDeskConfig, taskId: string): Promise<unknown> {
const tasksResponse = coreInternalFetch("/api/tasks?limit=100");
const tasks = (tasksResponse as { body?: { tasks?: Array<{ id?: string }> } }).body?.tasks ?? [];
const task = taskId === "latest" ? tasks[0] : tasks.find((item) => item.id === taskId);
return { tasksResponse, taskId, task: task ?? null };
}
export async function debugDispatch(
config: UniDeskConfig,
providerId: string,
command: DebugDispatchCommand,
payload?: Record<string, unknown>,
waitMs = 0,
): Promise<unknown> {
const dispatchPayload = payload ?? (command === "provider.upgrade" ? { source: "cli-debug", mode: "plan" } : { source: "cli-debug" });
const dispatch = coreInternalFetch("/api/dispatch", {
method: "POST",
body: { providerId, command, payload: dispatchPayload },
});
const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
const wait = waitMs > 0 && taskId.length > 0 ? await waitForTask(taskId, waitMs) : null;
return { dispatch, wait };
}