diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index a91cc547..a6d5401e 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -191,9 +191,12 @@ D601 Code Queue runner 的长期 skills source of truth 是宿主 `/home/ubuntu/ 执行面 `/health`、`/api/dev-ready` 和 `/api/runtime-preflight` 必须输出同一份只读 skill availability report。稳定字段包括 `source`、`target`、`requiredSkills`、`missingSkills`、`degraded`、`blocker` 和 `valuesPrinted=false`。`requiredSkills` 至少覆盖 `docs-spec`、`cli-spec`、`frontend-design` 与 `playwright-cli`;如果目标目录缺失、不是只读挂载、必需 skill 缺失,或拼写错误路径存在,报告必须显示 `ok=false` 和结构化 `blocker`,不能把 runner 能力缺口伪装成业务任务失败。 -受控加载路径是更新宿主 `/home/ubuntu/.agents/skills` 后让 Code Queue Pod 通过 hostPath 读取;在线热更新只能作为临时 runbook,长期验收必须以 manifest/source-of-truth、结构化 health/preflight 和合同测试为准。需要验证时优先运行: +执行面还必须提供 dry-run skills sync/preflight 合同:稳定入口是 `GET /api/skills-sync?dryRun=1`,CLI 入口是 `bun scripts/cli.ts codex skills-sync --dry-run [--full]`。该合同只描述受控 hostPath 生命周期,不复制文件、不从任意路径静默加载、不重启服务、不 rollout Pod、不读取 Secret。默认输出保持紧凑,必须报告 source、target、expected env/mount、required skill 列表、source/target skill counts、missing source/target skills、permission failure count、plannedActions 和修复指令;逐 skill 细节、完整 permission failure 和原始报告只能通过 `--full` 显式展开。非 dry-run 请求必须失败。 + +受控生命周期是更新宿主 `/home/ubuntu/.agents/skills` 这一 approved source,然后让生产和 dev Code Queue Pod 通过 manifest 中的 read-only hostPath 挂载读取 `/root/.agents/skills`。在线热更新只能作为临时恢复手段,长期验收必须以 manifest/source-of-truth、dry-run sync contract、结构化 health/preflight 和合同测试为准。需要验证时优先运行: ```bash +bun scripts/cli.ts codex skills-sync --dry-run bun scripts/code-queue-runner-skills-contract-test.ts bun scripts/cli.ts codex pr-preflight --remote --issue ``` diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts index 0f337757..14e00069 100644 --- a/scripts/code-queue-runner-skills-contract-test.ts +++ b/scripts/code-queue-runner-skills-contract-test.ts @@ -1,5 +1,7 @@ import { readFileSync } from "node:fs"; -import { collectSkillAvailability } from "../src/components/microservices/code-queue/src/skill-availability"; +import { collectSkillAvailability, collectSkillSyncPreflight } from "../src/components/microservices/code-queue/src/skill-availability"; +import { codexPrPreflightQueryForTest } from "./src/code-queue"; +import { summarizeMicroserviceObservation } from "./src/microservices"; type JsonRecord = Record; @@ -21,6 +23,9 @@ const devManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3 const runtimePreflight = readFileSync("src/components/microservices/code-queue/src/runtime-preflight.ts", "utf8"); const indexSource = readFileSync("src/components/microservices/code-queue/src/index.ts", "utf8"); const skillModule = readFileSync("src/components/microservices/code-queue/src/skill-availability.ts", "utf8"); +const codeQueueCli = readFileSync("scripts/src/code-queue.ts", "utf8"); +const microserviceCli = readFileSync("scripts/src/microservices.ts", "utf8"); +const helpSource = readFileSync("scripts/src/help.ts", "utf8"); const promptSource = readFileSync("src/components/microservices/code-queue/src/prompts.ts", "utf8"); const docsReference = readFileSync("docs/reference/code-queue-supervision.md", "utf8"); const forbiddenPathLiteral = [".ag", "nets/skills"].join(""); @@ -69,17 +74,142 @@ assertCondition(missing.blocker === "skills-target-missing", "missing target sho assertCondition(missing.missingSkills.includes("docs-spec") && missing.missingSkills.includes("cli-spec"), "missing target should list required missing skills", missing); assertCondition(missing.valuesPrinted === false, "missing report must also declare valuesPrinted=false"); +const syncDryRun = collectSkillSyncPreflight({ + source: "/home/ubuntu/.agents/skills", + target: "/path/that/does/not/exist/for-code-queue-skills-test", + requiredSkills: ["docs-spec", "cli-spec"], +}); +assertCondition(syncDryRun.dryRun === true && syncDryRun.mutation === false, "skills sync contract must be dry-run and non-mutating", syncDryRun); +assertCondition(syncDryRun.syncMode === "hostPath-read-only-projection", "skills sync must describe the hostPath projection lifecycle", syncDryRun); +assertCondition(syncDryRun.source.path === "/home/ubuntu/.agents/skills", "skills sync must expose source", syncDryRun.source); +assertCondition(syncDryRun.target.path === "/path/that/does/not/exist/for-code-queue-skills-test", "skills sync must expose target", syncDryRun.target); +assertCondition(syncDryRun.expected.source === "/home/ubuntu/.agents/skills", "skills sync must expose stable expected source", syncDryRun.expected); +assertCondition(syncDryRun.expected.target === "/root/.agents/skills", "skills sync must expose stable expected target", syncDryRun.expected); +assertCondition(syncDryRun.expected.env === "UNIDESK_SKILLS_PATH" && syncDryRun.expected.envValue === "/root/.agents/skills", "skills sync must expose env contract", syncDryRun.expected); +assertCondition(syncDryRun.counts.requiredSkills === 2, "skills sync must expose required skill count", syncDryRun.counts); +assertCondition(syncDryRun.counts.targetSkills === 0 && syncDryRun.counts.missingTargetSkills === 2, "skills sync must expose target counts and missing count", syncDryRun.counts); +assertCondition(syncDryRun.missing.targetSkills.includes("docs-spec") && syncDryRun.missing.targetSkills.includes("cli-spec"), "skills sync must expose missing target skills", syncDryRun.missing); +assertCondition(syncDryRun.blocker === "unapproved-target", "arbitrary target paths must be blocked before silent copying", syncDryRun); +assertCondition(syncDryRun.plannedActions.copy === false && syncDryRun.plannedActions.copyFromArbitraryPath === false, "skills sync dry-run must not plan arbitrary copy", syncDryRun.plannedActions); +assertCondition(syncDryRun.plannedActions.restartRequired === false && syncDryRun.plannedActions.readsSecrets === false, "skills sync dry-run must not require restart or read secrets", syncDryRun.plannedActions); +assertCondition(Array.isArray(syncDryRun.instructions) && syncDryRun.instructions.some((item) => item.includes("read-only hostPath projection")), "skills sync must include lifecycle instructions", syncDryRun.instructions); +assertCondition(syncDryRun.valuesPrinted === false, "skills sync must declare valuesPrinted=false", syncDryRun); +assertCondition(!JSON.stringify(syncDryRun).includes(forbiddenPathLiteral), "skills sync report must not propagate misspelled path literal"); + +const missingTargetSync = collectSkillSyncPreflight({ target: "/path/that/does/not/exist/for-code-queue-skills-test" }); +assertCondition(missingTargetSync.blocker === "unapproved-target", "non-default target must be rejected as unapproved", missingTargetSync); + assertCondition(runtimePreflight.includes("skills: SkillAvailabilityReport"), "runtime preflight type must include skills report"); +assertCondition(runtimePreflight.includes("skillsSync: SkillSyncPreflightReport"), "runtime preflight type must include skills sync report"); assertCondition(runtimePreflight.includes("collectSkillAvailability"), "runtime preflight must collect skills availability"); -assertCondition(runtimePreflight.includes("skills.ok && ports.codex.ok"), "runtime preflight ok must depend on skills.ok"); +assertCondition(runtimePreflight.includes("collectSkillSyncPreflight"), "runtime preflight must collect skills sync preflight"); +assertCondition(runtimePreflight.includes("skills.ok && skillsSync.ok && ports.codex.ok"), "runtime preflight ok must depend on skills and skillsSync"); assertCondition(indexSource.includes("const skillsReady = skills.ok === true"), "dev-ready must gate on structured skills ok"); +assertCondition(indexSource.includes("collectSkillsSyncPreflight"), "runtime index must expose skills sync preflight"); +assertCondition(indexSource.includes("/api/skills-sync"), "runtime must expose a dry-run skills sync endpoint"); +assertCondition(indexSource.includes("pass dryRun=1"), "skills sync endpoint must reject non-dry-run calls"); +assertCondition(codeQueueCli.includes("failureKind: \"dry-run-required\""), "codex skills-sync CLI must require --dry-run with structured output"); +assertCondition(codeQueueCli.includes("codex skills-sync is dry-run only; pass --dry-run"), "codex skills-sync CLI must explain the dry-run requirement"); +assertCondition(codeQueueCli.includes("Code Queue skills sync dry-run could not reach the control plane"), "codex skills-sync CLI must return structured control-plane failure output"); +assertCondition(codeQueueCli.includes("compact-skills-sync-control-plane-failure"), "codex skills-sync CLI must keep control-plane failure output compact"); +assertCondition(codeQueueCli.includes("compactSkillsSyncStatus"), "codex CLI must compact skills sync output"); +assertCondition(codeQueueCli.includes("runner-skills-blocker"), "codex preflight must classify skill lifecycle blockers"); +assertCondition(microserviceCli.includes("compactSkillSync"), "microservice health summary must compact skills sync output"); +assertCondition(helpSource.includes("codex skills-sync --dry-run"), "CLI help must document the skills sync dry-run command"); +assertCondition(docsReference.includes("codex skills-sync --dry-run"), "reference docs must document the skills sync dry-run command"); + +const preflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote"], { + config: null, + coreFetch: () => ({ + ok: true, + status: 200, + body: { + runtimePreflight: { + ok: false, + checkedAt: "2026-05-23T00:00:00.000Z", + cwd: "/workspace/unidesk", + pid: 601, + skills: missing, + skillsSync: syncDryRun, + ports: {}, + pullRequestDelivery: { + ok: true, + checkedAt: "2026-05-23T00:00:00.000Z", + tools: {}, + unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true }, + authBroker: { ok: true, configured: true, source: "auth-broker" }, + credentials: { + ghTokenPresent: false, + githubTokenPresent: false, + ghHostsConfigPresent: false, + gitCredentialsPresent: false, + }, + git: { + insideWorktree: true, + branch: "code-queue/issue-68-runner-skills-lifecycle", + head: "abc1234", + originMaster: "def5678", + remoteOrigin: "git@github.com:pikasTech/unidesk.git", + home: "/root", + homeWritable: true, + knownHostsPresent: true, + privateKeyPresent: true, + }, + githubContext: { + host: "github.com", + apiBaseUrl: "https://api.github.com", + repo: "pikasTech/unidesk", + issueProbeNumber: 68, + }, + egress: { proxy: {} }, + remote: null, + limitations: [], + risks: [], + }, + }, + }, + }), +}), "preflight summary"); +const preflight = asRecord(preflightSummary.preflight, "preflight"); +const preflightSkills = asRecord(preflight.skills, "preflight.skills"); +const preflightSkillsSync = asRecord(preflight.skillsSync, "preflight.skillsSync"); +assertCondition(preflightSummary.failureKind === "runner-skills-blocker", "compact preflight should classify missing skills as runner-skills-blocker", preflightSummary); +assertCondition(preflightSummary.degradedReason === "unapproved-target", "compact preflight degraded reason should use the skills sync blocker first", preflightSummary); +assertCondition(preflightSkills.target === "/path/that/does/not/exist/for-code-queue-skills-test", "compact preflight must show skills target", preflightSkills); +assertCondition(preflightSkillsSync.dryRun === true && preflightSkillsSync.mutation === false, "compact preflight must show non-mutating skills sync dry-run", preflightSkillsSync); +assertCondition(asRecord(preflightSkillsSync.counts, "preflight.skillsSync.counts").missingTargetSkills === 2, "compact preflight must show missing target count", preflightSkillsSync); +assertCondition(asRecord(preflightSkillsSync.plannedActions, "preflight.skillsSync.plannedActions").copy === false, "compact preflight must show no copy action", preflightSkillsSync); +assertCondition(preflightSkillsSync.valuesPrinted === false, "compact preflight skills sync must declare valuesPrinted=false", preflightSkillsSync); +assertCondition(!JSON.stringify(preflightSkillsSync).includes(forbiddenPathLiteral), "compact preflight must not propagate misspelled path literal"); + +const healthSummary = asRecord(summarizeMicroserviceObservation("health", "code-queue", { + ok: true, + status: 200, + body: { + ok: false, + service: "code-queue", + skills: missing, + skillsSync: syncDryRun, + }, +}, []), "microservice health summary"); +const microservice = asRecord(healthSummary.microservice, "microservice"); +const healthCompact = asRecord(microservice.summary, "microservice.summary"); +const healthSkills = asRecord(healthCompact.skills, "microservice.summary.skills"); +const healthSkillsSync = asRecord(healthCompact.skillsSync, "microservice.summary.skillsSync"); +assertCondition(healthSkills.target === "/path/that/does/not/exist/for-code-queue-skills-test", "compact health must show skills target", healthSkills); +assertCondition(healthSkillsSync.dryRun === true && healthSkillsSync.mutation === false, "compact health must show dry-run skills sync", healthSkillsSync); +assertCondition(asRecord(healthSkillsSync.counts, "microservice.summary.skillsSync.counts").missingTargetSkills === 2, "compact health must show missing target count", healthSkillsSync); +assertCondition(asRecord(healthSkillsSync.plannedActions, "microservice.summary.skillsSync.plannedActions").copyFromArbitraryPath === false, "compact health must show arbitrary copy is blocked", healthSkillsSync); +assertCondition(!JSON.stringify(healthSkillsSync).includes(forbiddenPathLiteral), "compact health must not propagate misspelled path literal"); process.stdout.write(`${JSON.stringify({ ok: true, checks: [ "production Code Queue mounts /home/ubuntu/.agents/skills read-only at /root/.agents/skills", "skill availability report exposes source, target, requiredSkills, missingSkills, degraded/blocker and valuesPrinted=false", - "runtime-preflight and dev-ready use the same structured skill report", + "skills sync dry-run reports source, target, counts, missing skills, permission failures, instructions and no-copy actions", + "runtime-preflight, dev-ready, health and PR preflight use the same structured skill and sync reports", + "default health/preflight summaries expose bounded skills lifecycle evidence and --full expansion", "misspelled skills paths are only surfaced as a forbidden diagnostic risk", ], observedRunner: { @@ -87,6 +217,8 @@ process.stdout.write(`${JSON.stringify({ target: available.target, ok: available.ok, missingSkills: available.missingSkills, + syncDryRunOk: syncDryRun.ok, + syncDryRunBlocker: syncDryRun.blocker, valuesPrinted: available.valuesPrinted, }, }, null, 2)}\n`); diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 02c04e68..abbff91d 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -305,7 +305,12 @@ interface CodexPrPreflightOptions { full: boolean; } -type CodeQueuePrPreflightFailureKind = "auth-missing" | "proxy-gap" | "git-remote-gap" | "control-plane-missing" | "target-stack-not-running"; +interface CodexSkillsSyncOptions { + dryRun: boolean; + full: boolean; +} + +type CodeQueuePrPreflightFailureKind = "auth-missing" | "runner-skills-blocker" | "proxy-gap" | "git-remote-gap" | "control-plane-missing" | "target-stack-not-running"; type CodeQueueObservationGapKind = "runner-local-observation-gap" | "control-plane-observation-gap" | null; interface CodeQueuePrPreflightTransport { @@ -1742,6 +1747,17 @@ function parsePrPreflightOptions(args: string[]): CodexPrPreflightOptions { }; } +function parseSkillsSyncOptions(args: string[]): CodexSkillsSyncOptions { + assertKnownOptions(args, { + flags: ["--dry-run", "--dryRun", "--full", "--raw"], + }, "codex skills-sync"); + const dryRun = hasFlag(args, "--dry-run") || hasFlag(args, "--dryRun"); + return { + dryRun, + full: hasFlag(args, "--full") || hasFlag(args, "--raw"), + }; +} + function parseJudgeOptions(args: string[]): CodexJudgeOptions { assertKnownOptions(args, { flags: ["--dry-run", "--no-call", "--include-prompt"], @@ -3072,6 +3088,56 @@ function compactSkillsStatus(value: unknown): Record | null { }; } +function compactSkillPathReport(value: unknown): Record | null { + const record = asRecord(value); + if (record === null) return null; + return { + path: record.path ?? null, + approved: record.approved ?? false, + exists: record.exists ?? false, + directory: record.directory ?? false, + readable: record.readable ?? false, + writable: record.writable ?? false, + readonly: record.readonly ?? false, + mountPoint: record.mountPoint ?? null, + skillCount: record.skillCount ?? 0, + requiredSkills: Array.isArray(record.requiredSkills) ? record.requiredSkills.map(String) : [], + missingSkills: Array.isArray(record.missingSkills) ? record.missingSkills.map(String) : [], + error: record.error ?? null, + }; +} + +function compactSkillsSyncStatus(value: unknown, full = false): Record | null { + const record = asRecord(value); + if (record === null) return null; + const source = compactSkillPathReport(record.source); + const target = compactSkillPathReport(record.target); + const compact: Record = { + ok: record.ok ?? false, + degraded: record.degraded ?? true, + blocker: record.blocker ?? null, + checkedAt: record.checkedAt ?? null, + mode: record.mode ?? "dry-run", + dryRun: record.dryRun ?? true, + mutation: record.mutation ?? false, + syncMode: record.syncMode ?? null, + source, + target, + expected: record.expected ?? null, + counts: record.counts ?? null, + missing: record.missing ?? null, + permissionFailures: Array.isArray(record.permissionFailures) ? record.permissionFailures.slice(0, full ? undefined : 4) : [], + permissionFailureCount: Array.isArray(record.permissionFailures) ? record.permissionFailures.length : 0, + pathSpelling: record.pathSpelling ?? null, + plannedActions: record.plannedActions ?? null, + instructions: Array.isArray(record.instructions) ? record.instructions.map(String).slice(0, full ? undefined : 4) : [], + commands: record.commands ?? null, + valuesPrinted: record.valuesPrinted ?? false, + }; + if (full) compact.rawSkillsSync = record; + return compact; +} + function codeQueueDevReady(): unknown { const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath("/api/dev-ready"))); const devReady = asRecord(response.body.devReady) ?? {}; @@ -3085,10 +3151,101 @@ function codeQueueDevReady(): unknown { codexConfig: devReady.codexConfig ?? null, ssh: devReady.ssh ?? null, skills: compactSkillsStatus(devReady.skills), + skillsSync: compactSkillsSyncStatus(devReady.skillsSync), }, commands: { health: "bun scripts/cli.ts codex dev-ready", raw: "bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw", + skillsSync: "bun scripts/cli.ts codex skills-sync --dry-run", + }, + }; +} + +function codeQueueSkillsSync(args: string[]): unknown { + const options = parseSkillsSyncOptions(args); + if (!options.dryRun) { + return { + ok: false, + failureKind: "dry-run-required", + runnerDisposition: "business-failed", + message: "codex skills-sync is dry-run only; pass --dry-run", + mutation: false, + commands: { + dryRun: "bun scripts/cli.ts codex skills-sync --dry-run", + full: "bun scripts/cli.ts codex skills-sync --dry-run --full", + }, + }; + } + const rawResponse = coreInternalFetch(codeQueueProxyPath("/api/skills-sync?dryRun=1")); + const response = asRecord(rawResponse); + if (response?.ok !== true) { + const targetStack = asRecord(response?.targetStack); + const observed = asRecord(response?.observed); + const missingContainers = Array.isArray(targetStack?.missingContainers) ? targetStack.missingContainers.map(String) : []; + const relatedContainers = Array.isArray(targetStack?.relatedContainers) ? targetStack.relatedContainers : []; + return { + ok: false, + dryRun: true, + mutation: false, + runnerDisposition: response?.runnerDisposition ?? "infra-blocked", + failureKind: response?.failureKind ?? "control-plane-missing", + degradedReason: response?.degradedReason ?? "backend-core-proxy-unavailable", + message: response?.message ?? response?.stderrTail ?? response?.stdoutTail ?? "Code Queue skills sync dry-run could not reach the control plane", + controlPlane: { + mode: "local-backend-core", + localBackendCoreMissing: response?.failureKind === "target-stack-not-running", + schedulerStateChanged: false, + liveRunnerHostPathMutated: false, + }, + targetStackSummary: targetStack === null ? null : { + missingContainers, + missingContainerCount: missingContainers.length, + relatedContainerCount: relatedContainers.length, + verifyOnlyObserved: targetStack.verifyOnlyObserved ?? false, + }, + observed: observed === null ? null : { + commandExitCode: observed.commandExitCode ?? null, + stderrTail: typeof observed.stderrTail === "string" ? textView(observed.stderrTail, false, 600) : null, + }, + outputPolicy: { + default: "compact-skills-sync-control-plane-failure", + unrelatedDiagnosticsOmitted: true, + full: "use microservice health/proxy commands only when control-plane diagnostics are needed", + }, + commands: { + retry: "bun scripts/cli.ts codex skills-sync --dry-run", + full: "bun scripts/cli.ts codex skills-sync --dry-run --full", + health: "bun scripts/cli.ts microservice health code-queue", + rawProxy: "bun scripts/cli.ts microservice proxy code-queue /api/skills-sync?dryRun=1 --raw --full", + }, + }; + } + const body = asRecord(response.body); + if (body?.ok !== true) { + return { + ok: false, + dryRun: true, + mutation: false, + runnerDisposition: "infra-blocked", + failureKind: "runtime-skills-sync-missing", + degradedReason: "runtime-skills-sync-response-invalid", + message: "Code Queue skills sync dry-run response did not include a valid body", + upstream: { ok: response.ok, status: response.status ?? null }, + bodyPreview: body, + commands: { + retry: "bun scripts/cli.ts codex skills-sync --dry-run", + rawProxy: "bun scripts/cli.ts microservice proxy code-queue /api/skills-sync?dryRun=1 --raw --full", + }, + }; + } + const skillsSync = asRecord(body.skillsSync); + return { + upstream: { ok: response.ok, status: response.status ?? null }, + skillsSync: compactSkillsSyncStatus(skillsSync, options.full), + outputPolicy: { + default: "compact-skills-sync-dry-run", + full: "bun scripts/cli.ts codex skills-sync --dry-run --full", + mutation: false, }, }; } @@ -3457,6 +3614,8 @@ function maybeFullPrPreflightObservations(options: CodexPrPreflightOptions, loca function compactPrRuntimePreflight(preflight: Record, options: CodexPrPreflightOptions): Record { const pull = asRecord(preflight.pullRequestDelivery) ?? {}; + const skills = compactSkillsStatus(preflight.skills); + const skillsSync = compactSkillsSyncStatus(preflight.skillsSync, options.full); const tools = asRecord(pull.tools) ?? {}; const unideskGhCli = compactUniDeskGhCliStatus(pull.unideskGhCli); const authBrokerRuntime = compactAuthBrokerRuntimeStatus(pull.authBroker); @@ -3472,20 +3631,26 @@ function compactPrRuntimePreflight(preflight: Record, options: const limitations = Array.isArray(pull.limitations) ? pull.limitations.map(String) : []; const risks = Array.isArray(pull.risks) ? pull.risks.map(String) : []; const ok = preflight.ok === true && tokenCoverage.ok === true; + const skillsBlocked = skills !== null && skills.ok !== true; + const skillsSyncBlocked = skillsSync !== null && skillsSync.ok !== true; const failureKind = !tokenCoverage.ok ? "auth-missing" - : limitations.some((item) => item.includes("git ls-remote") || item.includes("git push --dry-run failed")) - ? "git-remote-gap" - : !preflight.ok - ? "proxy-gap" - : null; + : skillsBlocked || skillsSyncBlocked + ? "runner-skills-blocker" + : limitations.some((item) => item.includes("git ls-remote") || item.includes("git push --dry-run failed")) + ? "git-remote-gap" + : !preflight.ok + ? "proxy-gap" + : null; const degradedReason = failureKind === "auth-missing" ? "auth-broker-needed" - : failureKind === "git-remote-gap" - ? "git remote probe failed" - : failureKind === "proxy-gap" - ? limitations.find((item) => item.includes("proxy") || item.includes("auth") || item.includes("egress") || item.includes("reachable")) ?? null - : null; + : failureKind === "runner-skills-blocker" + ? typeof skillsSync?.blocker === "string" ? skillsSync.blocker : typeof skills?.blocker === "string" ? skills.blocker : "runner-skills-degraded" + : failureKind === "git-remote-gap" + ? "git remote probe failed" + : failureKind === "proxy-gap" + ? limitations.find((item) => item.includes("proxy") || item.includes("auth") || item.includes("egress") || item.includes("reachable")) ?? null + : null; const defaultPushDryRunRef = "refs/heads/probe/code-queue-pr-capability-dryrun"; const pushDryRunRef = options.pushDryRunRef ?? defaultPushDryRunRef; const targetBranch = "master"; @@ -3506,6 +3671,8 @@ function compactPrRuntimePreflight(preflight: Record, options: cwd: preflight.cwd ?? null, pid: preflight.pid ?? null, }, + skills, + skillsSync, tokenCoverage, authBroker: authBrokerNeededStatus(tokenCoverage, authBrokerRuntime, systemGhBinary, unideskGhCli), authScopeSummary, @@ -4138,6 +4305,7 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]) assertKnownOptions(args.slice(1), {}, `codex ${action}`); return codeQueueDevReady(); } + if (action === "skills-sync") return codeQueueSkillsSync(args.slice(1)); if (action === "pr-preflight" || action === "runtime-preflight") return codeQueuePrPreflight(args.slice(1), { config }); if (action === "output") { const taskId = requireTaskId(taskIdArg, "codex output"); @@ -4173,5 +4341,5 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]) const taskId = requireTaskId(taskIdArg, "codex steer"); return codexSteerTask(taskId, args.slice(2)); } - throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel"); + throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel"); } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 5b60e84c..a5746762 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -53,7 +53,8 @@ export function rootHelp(): unknown { { command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." }, { command: "codex deploy [--provider-id D601] [--timeout-ms N]", description: "Disabled legacy Code Queue deploy path; use the dev-only artifact consumer instead." }, { command: "codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]", description: "Submit a Code Queue task through backend-core -> code-queue proxy; --dry-run shows the structured request, while real success only confirms the write and task id." }, - { command: "codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N]", description: "Read-only PR admission check against the D601 scheduler/runner token, GitHub egress, repo visibility, optional push dry-run, and PR body/create dry-run guard." }, + { command: "codex skills-sync --dry-run [--full]", description: "Inspect the controlled runner skills hostPath lifecycle contract without copying files, restarting services, reading secrets, or mutating live runner paths." }, + { command: "codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N]", description: "Read-only PR admission check against the D601 scheduler/runner token, GitHub egress, repo visibility, skills lifecycle health, optional push dry-run, and PR body/create dry-run guard." }, { command: "codex task [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch the bounded review view by default; --detail is still capped, while --full/trace/output explicitly expand evidence." }, { command: "codex tasks [--view supervisor|full] [--queue id] [--status status[,status]] [--unread|--unread-only] [--limit N] [--before-id id]", description: "Show the low-noise supervisor view by default: compact task rows, tiny local sections, diagnostics, and drill-down commands; use --view full for detailed rows." }, { command: "codex output [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Code Queue output records; default caps large limits/text previews, --full-text explicitly expands one seq window." }, @@ -244,7 +245,7 @@ function scheduleHelp(): unknown { function codexHelp(): unknown { return { - command: "codex deploy|submit|task|tasks|output|read|dev-ready|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move", + command: "codex deploy|submit|task|tasks|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move", output: "json", usage: [ "bun scripts/cli.ts codex deploy # disabled legacy deployment entry", @@ -256,6 +257,7 @@ function codexHelp(): unknown { "bun scripts/cli.ts codex output [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", "bun scripts/cli.ts codex read ", "bun scripts/cli.ts codex dev-ready", + "bun scripts/cli.ts codex skills-sync --dry-run [--full]", "bun scripts/cli.ts codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N]", "bun scripts/cli.ts codex judge --attempt N [--dry-run] [--include-prompt]", "bun scripts/cli.ts codex steer [prompt|--prompt-file path|--prompt-stdin] [--dry-run] [--no-retry|--retry-attempts N]", @@ -280,7 +282,7 @@ function codexHelp(): unknown { }, disclosure: { defaultPolicy: "low-noise JSON by default; write commands confirm persistence, list/detail/output commands return bounded summaries with drill-down commands", - expand: ["codex task --full", "codex task --trace --limit N", "codex output --after-seq N --limit N --full-text", "codex tasks --view full --limit N"], + expand: ["codex task --full", "codex task --trace --limit N", "codex output --after-seq N --limit N --full-text", "codex tasks --view full --limit N", "codex skills-sync --dry-run --full"], }, description: "Operate Code Queue through the stable backend-core private proxy path. Real submit/steer success is a low-noise write confirmation and does not echo prompt text.", }; diff --git a/scripts/src/microservices.ts b/scripts/src/microservices.ts index 33f7e3e3..5f6b6560 100644 --- a/scripts/src/microservices.ts +++ b/scripts/src/microservices.ts @@ -367,6 +367,86 @@ function compactQueueHealth(value: unknown): Record | null { }; } +function compactSkillAvailability(value: unknown): Record | null { + const skills = asRecord(value); + if (skills === null) return null; + return { + ok: skills.ok ?? false, + path: skills.path ?? null, + source: skills.source ?? null, + target: skills.target ?? null, + mountPoint: skills.mountPoint ?? null, + exists: skills.exists ?? false, + available: skills.available ?? false, + degraded: skills.degraded ?? true, + blocker: skills.blocker ?? null, + readonly: skills.readonly ?? false, + skillCount: skills.skillCount ?? 0, + requiredSkills: Array.isArray(skills.requiredSkills) ? skills.requiredSkills.map(String) : [], + missingSkills: Array.isArray(skills.missingSkills) ? skills.missingSkills.map(String) : [], + valuesPrinted: skills.valuesPrinted ?? false, + pathSpelling: compactRecordFields(asRecord(skills.pathSpelling), [ + "expectedTarget", + "forbiddenPathChecked", + "forbiddenPathExists", + "forbiddenPathMustNotBeUsed", + ]), + repairHint: skills.repairHint ?? null, + }; +} + +function compactSkillSync(value: unknown): Record | null { + const sync = asRecord(value); + if (sync === null) return null; + const source = compactRecordFields(asRecord(sync.source), [ + "path", + "approved", + "exists", + "directory", + "readable", + "writable", + "readonly", + "mountPoint", + "skillCount", + "requiredSkills", + "missingSkills", + "error", + ]); + const target = compactRecordFields(asRecord(sync.target), [ + "path", + "approved", + "exists", + "directory", + "readable", + "writable", + "readonly", + "mountPoint", + "skillCount", + "requiredSkills", + "missingSkills", + "error", + ]); + const permissionFailures = Array.isArray(sync.permissionFailures) ? sync.permissionFailures : []; + return { + ok: sync.ok ?? false, + degraded: sync.degraded ?? true, + blocker: sync.blocker ?? null, + mode: sync.mode ?? "dry-run", + dryRun: sync.dryRun ?? true, + mutation: sync.mutation ?? false, + syncMode: sync.syncMode ?? null, + source, + target, + counts: sync.counts ?? null, + missing: sync.missing ?? null, + permissionFailureCount: permissionFailures.length, + permissionFailures: permissionFailures.slice(0, 4), + plannedActions: sync.plannedActions ?? null, + commands: sync.commands ?? null, + valuesPrinted: sync.valuesPrinted ?? false, + }; +} + function compactMicroserviceBody(body: unknown, serviceId: string): Record | null { const record = asRecord(body); if (record === null) return null; @@ -388,6 +468,8 @@ function compactMicroserviceBody(body: unknown, serviceId: string): Record 0; const skills = collectSkillsStatus() as Record; + const skillsSync = collectSkillsSyncPreflight() as Record; const skillsReady = skills.ok === true; const runtimePreflight = runtimePreflightJson(collectRuntimePreflight({ includeRemote: false, includePushDryRun: false })); - const ok = missingTools.length === 0 && dockerProbe.ok && composeProbe.ok && workdirExists && dockerSocketExists && codexConfigReady && sshSharedReady && skillsReady; + const ok = missingTools.length === 0 && dockerProbe.ok && composeProbe.ok && workdirExists && dockerSocketExists && codexConfigReady && sshSharedReady && skillsReady && skillsSync.ok === true; const value: JsonValue = { ok, missingTools, @@ -2506,6 +2511,7 @@ function collectDevReady(): JsonValue { ready: sshSharedReady, }, skills, + skillsSync, runtimePreflight, }; devReadyCache = { checkedAtMs: now, value }; @@ -5195,6 +5201,7 @@ async function route(req: Request): Promise { databaseLastError, modelProviderConfig: codeModelProviderSourceContract(config), skills: collectSkillsStatus(), + skillsSync: collectSkillsSyncPreflight(), startedAt: serviceStartedAt, }, 503); return jsonResponse({ @@ -5213,6 +5220,7 @@ async function route(req: Request): Promise { modelProviderConfig: codeModelProviderSourceContract(config), executionDiagnostics: executionDiagnosticsForTasks(state.tasks), skills: collectSkillsStatus(), + skillsSync: collectSkillsSyncPreflight(), egressProxy: await providerGatewayEgressProxyStatus(), oaEventPublisher: oaEventPublisherStatus(), startedAt: serviceStartedAt, @@ -5221,6 +5229,16 @@ async function route(req: Request): Promise { if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-parseLimit(url)) }); if (url.pathname === "/api/events" && req.method === "GET") return jsonResponse({ ok: false, error: "Code Queue private SSE was removed; subscribe to oa-event-flow /api/events/stream with service:code-queue tags." }, 410); if (url.pathname === "/api/dev-ready" && req.method === "GET") return jsonResponse({ ok: true, devReady: collectDevReady() }); + if (url.pathname === "/api/skills-sync" && req.method === "GET") { + const dryRun = url.searchParams.get("dryRun") === "1" || url.searchParams.get("dry-run") === "1"; + if (!dryRun) return jsonResponse({ + ok: false, + error: "skills sync is dry-run only from the runtime API; pass dryRun=1", + mutation: false, + valuesPrinted: false, + }, 400); + return jsonResponse({ ok: true, skillsSync: collectSkillsSyncPreflight() }); + } if (url.pathname === "/api/runtime-preflight" && req.method === "GET") { return jsonResponse({ ok: true, diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts index 1bd4d6fb..b12a46b0 100644 --- a/src/components/microservices/code-queue/src/runtime-preflight.ts +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import type { JsonValue } from "./types"; -import { collectSkillAvailability, type SkillAvailabilityReport } from "./skill-availability"; +import { collectSkillAvailability, collectSkillSyncPreflight, type SkillAvailabilityReport, type SkillSyncPreflightReport } from "./skill-availability"; export type RuntimePreflightAgentPort = "codex" | "opencode"; @@ -53,6 +53,7 @@ export interface RuntimePreflightReport { }; path: string; skills: SkillAvailabilityReport; + skillsSync: SkillSyncPreflightReport; ports: Record; pullRequestDelivery: PullRequestDeliveryPreflight; } @@ -648,13 +649,14 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): const checkedAt = new Date().toISOString(); const path = process.env.PATH ?? ""; const skills = collectSkillAvailability({ target: process.env.UNIDESK_SKILLS_PATH || "/root/.agents/skills" }); + const skillsSync = collectSkillSyncPreflight({ target: process.env.UNIDESK_SKILLS_PATH || "/root/.agents/skills" }); const ports = { codex: codexStatus(path, checkedAt), opencode: opencodeStatus(path, checkedAt), }; const pullRequestDelivery = collectPullRequestDeliveryPreflight(options, checkedAt); return { - ok: skills.ok && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, + ok: skills.ok && skillsSync.ok && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, checkedAt, cwd: process.cwd(), pid: process.pid, @@ -665,6 +667,7 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): }, path, skills, + skillsSync, ports, pullRequestDelivery, }; diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts index 0d9d9ecd..e030acf4 100644 --- a/src/components/microservices/code-queue/src/skill-availability.ts +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { accessSync, constants, existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { spawnSync } from "node:child_process"; import type { JsonValue } from "./types"; @@ -38,9 +38,95 @@ export interface SkillAvailabilityReport { valuesPrinted: false; } -const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"]; -const defaultSource = "/home/ubuntu/.agents/skills"; -const expectedTarget = "/root/.agents/skills"; +export interface SkillSyncPathReport { + path: string; + approved: boolean; + exists: boolean; + directory: boolean; + readable: boolean; + writable: boolean; + readonly: boolean; + mountPoint: string | null; + skillCount: number; + requiredSkills: string[]; + missingSkills: string[]; + skills: Array<{ name: string; present: boolean; skillMdPresent: boolean; path: string }>; + error: string | null; +} + +export interface SkillSyncPreflightOptions { + source?: string; + target?: string; + requiredSkills?: string[]; +} + +export interface SkillSyncPreflightReport { + ok: boolean; + degraded: boolean; + blocker: + | "unapproved-source" + | "unapproved-target" + | "skills-source-missing" + | "skills-source-not-directory" + | "skills-source-permission-failed" + | "skills-target-missing" + | "skills-target-not-directory" + | "skills-target-permission-failed" + | "skills-target-not-readonly" + | "required-source-skills-missing" + | "required-target-skills-missing" + | "forbidden-skills-path-present" + | null; + checkedAt: string; + mode: "dry-run"; + dryRun: true; + mutation: false; + syncMode: "hostPath-read-only-projection"; + source: SkillSyncPathReport; + target: SkillSyncPathReport; + expected: { + source: string; + target: string; + env: "UNIDESK_SKILLS_PATH"; + envValue: string; + mount: string; + requiredSkills: string[]; + }; + counts: { + sourceSkills: number; + targetSkills: number; + requiredSkills: number; + missingSourceSkills: number; + missingTargetSkills: number; + }; + missing: { + sourceSkills: string[]; + targetSkills: string[]; + }; + permissionFailures: Array<{ path: string; operation: string; error: string }>; + pathSpelling: SkillAvailabilityReport["pathSpelling"]; + plannedActions: { + copy: false; + writesSource: false; + writesTarget: false; + restartRequired: false; + readsSecrets: false; + copyFromArbitraryPath: false; + }; + instructions: string[]; + commands: { + dryRun: string; + full: string; + health: string; + runtimePreflight: string; + contractTest: string; + }; + valuesPrinted: false; +} + +export const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"]; +export const defaultSource = "/home/ubuntu/.agents/skills"; +export const expectedTarget = "/root/.agents/skills"; const forbiddenSkillsDirName = [".ag", "nets"].join(""); const forbiddenTargets = [`/root/${forbiddenSkillsDirName}/skills`, `/home/ubuntu/${forbiddenSkillsDirName}/skills`]; const skillDirectoryAliases: Record = { @@ -103,6 +189,90 @@ function skillStatus(target: string, requiredSkills: string[]): SkillAvailabilit }); } +function accessProbe(path: string, mode: number, operation: string): { ok: boolean; failure: { path: string; operation: string; error: string } | null } { + try { + accessSync(path, mode); + return { ok: true, failure: null }; + } catch (error) { + return { + ok: false, + failure: { + path, + operation, + error: error instanceof Error ? error.message : String(error), + }, + }; + } +} + +function emptySkillStatuses(target: string, requiredSkills: string[]): SkillAvailabilityReport["skills"] { + return requiredSkills.map((name) => ({ + name, + present: false, + skillMdPresent: false, + path: resolve(target, name), + })); +} + +function collectSyncPathReport(path: string, approved: boolean, requiredSkills: string[]): { report: SkillSyncPathReport; permissionFailures: SkillSyncPreflightReport["permissionFailures"] } { + const mountInfo = mountInfoForPath(path); + const exists = existsSync(path); + const permissionFailures: SkillSyncPreflightReport["permissionFailures"] = []; + let directory = false; + let readable = false; + let writable = false; + let skillCount = 0; + let skills = emptySkillStatuses(path, requiredSkills); + let error: string | null = null; + + if (exists) { + try { + const stat = statSync(path); + directory = stat.isDirectory(); + } catch (probeError) { + error = probeError instanceof Error ? probeError.message : String(probeError); + } + } + + if (exists && directory) { + const read = accessProbe(path, constants.R_OK | constants.X_OK, "read-directory"); + readable = read.ok; + if (read.failure !== null) permissionFailures.push(read.failure); + const write = accessProbe(path, constants.W_OK, "write-check"); + writable = write.ok; + if (readable) { + try { + const entries = readdirSync(path, { withFileTypes: true }); + skillCount = entries.filter((entry) => entry.isDirectory()).length; + skills = skillStatus(path, requiredSkills); + } catch (probeError) { + error = probeError instanceof Error ? probeError.message : String(probeError); + permissionFailures.push({ path, operation: "list-skills", error }); + } + } + } + + const missingSkills = skills.filter((skill) => !skill.present || !skill.skillMdPresent).map((skill) => skill.name); + return { + report: { + path, + approved, + exists, + directory, + readable, + writable, + readonly: exists && directory && (mountInfo.readonly === true || !writable), + mountPoint: mountInfo.mountPoint, + skillCount, + requiredSkills, + missingSkills, + skills, + error, + }, + permissionFailures, + }; +} + export function collectSkillAvailability(options: SkillAvailabilityOptions): SkillAvailabilityReport { const target = options.target; const source = options.source ?? defaultSource; @@ -183,6 +353,107 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski }; } +export function collectSkillSyncPreflight(options: SkillSyncPreflightOptions = {}): SkillSyncPreflightReport { + const sourcePath = options.source ?? defaultSource; + const targetPath = options.target ?? expectedTarget; + const requiredSkills = options.requiredSkills ?? defaultRequiredSkills; + const checkedAt = new Date().toISOString(); + const source = collectSyncPathReport(sourcePath, sourcePath === defaultSource, requiredSkills); + const target = collectSyncPathReport(targetPath, targetPath === expectedTarget, requiredSkills); + const forbiddenPathExists = forbiddenTargets.some((path) => existsSync(path)); + const permissionFailures = [...source.permissionFailures, ...target.permissionFailures]; + const blocker = !source.report.approved + ? "unapproved-source" + : !target.report.approved + ? "unapproved-target" + : !source.report.exists + ? "skills-source-missing" + : !source.report.directory + ? "skills-source-not-directory" + : !source.report.readable + ? "skills-source-permission-failed" + : !target.report.exists + ? "skills-target-missing" + : !target.report.directory + ? "skills-target-not-directory" + : !target.report.readable + ? "skills-target-permission-failed" + : !target.report.readonly + ? "skills-target-not-readonly" + : source.report.missingSkills.length > 0 + ? "required-source-skills-missing" + : target.report.missingSkills.length > 0 + ? "required-target-skills-missing" + : forbiddenPathExists + ? "forbidden-skills-path-present" + : null; + const ok = blocker === null; + return { + ok, + degraded: !ok, + blocker, + checkedAt, + mode: "dry-run", + dryRun: true, + mutation: false, + syncMode: "hostPath-read-only-projection", + source: source.report, + target: target.report, + expected: { + source: defaultSource, + target: expectedTarget, + env: "UNIDESK_SKILLS_PATH", + envValue: expectedTarget, + mount: `${defaultSource} mounted read-only to ${expectedTarget}`, + requiredSkills, + }, + counts: { + sourceSkills: source.report.skillCount, + targetSkills: target.report.skillCount, + requiredSkills: requiredSkills.length, + missingSourceSkills: source.report.missingSkills.length, + missingTargetSkills: target.report.missingSkills.length, + }, + missing: { + sourceSkills: source.report.missingSkills, + targetSkills: target.report.missingSkills, + }, + permissionFailures, + pathSpelling: { + expectedTarget, + forbiddenPathChecked: true, + forbiddenPathExists, + forbiddenPathMustNotBeUsed: true, + }, + plannedActions: { + copy: false, + writesSource: false, + writesTarget: false, + restartRequired: false, + readsSecrets: false, + copyFromArbitraryPath: false, + }, + instructions: [ + `Use ${defaultSource} as the only approved runner skills source and ${expectedTarget} as the only approved Code Queue container target.`, + "Keep the lifecycle as a read-only hostPath projection; this dry-run does not copy files, restart services, roll pods, or read secrets.", + "Update the approved source through the controlled skills installation path, then let production and dev Code Queue pods read it through the manifest mount.", + "Use the full command only when per-skill status or permission failure detail is needed.", + ], + commands: { + dryRun: "bun scripts/cli.ts codex skills-sync --dry-run", + full: "bun scripts/cli.ts codex skills-sync --dry-run --full", + health: "bun scripts/cli.ts microservice health code-queue", + runtimePreflight: "bun scripts/cli.ts codex pr-preflight --remote", + contractTest: "bun scripts/code-queue-runner-skills-contract-test.ts", + }, + valuesPrinted: false, + }; +} + export function skillAvailabilityJson(report: SkillAvailabilityReport): JsonValue { return report as unknown as JsonValue; } + +export function skillSyncPreflightJson(report: SkillSyncPreflightReport): JsonValue { + return report as unknown as JsonValue; +}