From 62c4da6b589a267136d0f2800e85723aa92b08e1 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 22 May 2026 15:24:00 +0000 Subject: [PATCH] fix(code-queue): preflight runner skills availability --- docs/reference/code-queue-supervision.md | 13 ++ .../code-queue-runner-skills-contract-test.ts | 92 +++++++++ scripts/src/check.ts | 3 + scripts/src/code-queue.ts | 10 +- .../microservices/code-queue/src/index.ts | 69 +------ .../code-queue/src/runtime-preflight.ts | 6 +- .../code-queue/src/skill-availability.ts | 188 ++++++++++++++++++ .../k3sctl-adapter/k3s/code-queue.k8s.yaml | 27 +++ 8 files changed, 340 insertions(+), 68 deletions(-) create mode 100644 scripts/code-queue-runner-skills-contract-test.ts create mode 100644 src/components/microservices/code-queue/src/skill-availability.ts diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 34f3c494..8a2f15b0 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -161,6 +161,19 @@ bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base m 该脚本只读调用 `gh auth status`,并执行 `gh pr create --dry-run` 与 `gh pr comment --dry-run`。它检查当前 shell 的 `GH_TOKEN/GITHUB_TOKEN` 是否存在、GitHub REST egress 是否可达、repo 是否可见,并且只输出 token 来源和存在性,不输出 token 值。它不能证明 Code Queue default scheduler 已注入 token;跨 queue 派单 admission 应使用 `codex pr-preflight`。 +### Runner Skill 可用性 + +D601 Code Queue runner 的长期 skills source of truth 是宿主 `/home/ubuntu/.agents/skills`,生产和 dev Code Queue Pod 都必须只读挂载到容器内 `/root/.agents/skills`,并设置 `UNIDESK_SKILLS_PATH=/root/.agents/skills`。不要使用或传播任何拼错的 skills 路径;诊断中只能把这类问题作为 forbidden path risk 暴露。 + +执行面 `/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 和合同测试为准。需要验证时优先运行: + +```bash +bun scripts/code-queue-runner-skills-contract-test.ts +bun scripts/cli.ts codex pr-preflight --remote --issue +``` + 指挥官审查 checklist: - PR base 是声明的目标分支,head branch 命名可追踪,远端 head commit 可 fetch。 diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts new file mode 100644 index 00000000..0f337757 --- /dev/null +++ b/scripts/code-queue-runner-skills-contract-test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs"; +import { collectSkillAvailability } from "../src/components/microservices/code-queue/src/skill-availability"; + +type JsonRecord = Record; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function asRecord(value: unknown, label: string): JsonRecord { + assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value); + return value as JsonRecord; +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +const productionManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml", "utf8"); +const devManifest = readFileSync("src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml", "utf8"); +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 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(""); + +assertCondition(!productionManifest.includes(forbiddenPathLiteral), "production manifest must not propagate misspelled skills path"); +assertCondition(!devManifest.includes(forbiddenPathLiteral), "dev manifest must not propagate misspelled skills path"); +assertCondition(!promptSource.includes(forbiddenPathLiteral), "runner prompt must not mention misspelled skills path"); +assertCondition(!skillModule.includes(forbiddenPathLiteral), "skill availability implementation must not propagate misspelled skills path literal"); +assertCondition(!docsReference.includes(forbiddenPathLiteral), "reference docs must not propagate misspelled skills path literal"); +assertCondition(countOccurrences(productionManifest, "name: UNIDESK_SKILLS_PATH") === 3, "production read/write/scheduler must set UNIDESK_SKILLS_PATH", { + count: countOccurrences(productionManifest, "name: UNIDESK_SKILLS_PATH"), +}); +assertCondition(countOccurrences(productionManifest, "mountPath: /root/.agents/skills") === 3, "production read/write/scheduler must mount skills target", { + count: countOccurrences(productionManifest, "mountPath: /root/.agents/skills"), +}); +assertCondition(countOccurrences(productionManifest, "path: /home/ubuntu/.agents/skills") === 3, "production read/write/scheduler must use hostPath source of truth", { + count: countOccurrences(productionManifest, "path: /home/ubuntu/.agents/skills"), +}); +assertCondition(countOccurrences(productionManifest, "name: skills-dir") >= 6, "production manifest must define skills-dir mounts and volumes", { + count: countOccurrences(productionManifest, "name: skills-dir"), +}); +assertCondition(devManifest.includes("path: /home/ubuntu/.agents/skills"), "dev manifest should keep the same hostPath source of truth"); + +const available = collectSkillAvailability({ + source: "/home/ubuntu/.agents/skills", + target: "/home/ubuntu/.agents/skills", + requiredSkills: ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"], +}); +assertCondition(available.source === "/home/ubuntu/.agents/skills", "skill report must expose source"); +assertCondition(available.target === "/home/ubuntu/.agents/skills", "skill report must expose target"); +assertCondition(Array.isArray(available.requiredSkills) && available.requiredSkills.includes("docs-spec"), "skill report must expose requiredSkills"); +assertCondition(Array.isArray(available.missingSkills), "skill report must expose missingSkills"); +assertCondition(available.valuesPrinted === false, "skill report must declare valuesPrinted=false"); +assertCondition(asRecord(available.pathSpelling, "pathSpelling").forbiddenPathMustNotBeUsed === true, "skill report must flag misspelled path risk without spreading the literal path"); +assertCondition(!JSON.stringify(available).includes(forbiddenPathLiteral), "skill report must not propagate misspelled path literal"); +assertCondition(!JSON.stringify(available).includes("GH_TOKEN"), "skill report must not include secret environment names unrelated to skills"); + +const missing = collectSkillAvailability({ + source: "/home/ubuntu/.agents/skills", + target: "/path/that/does/not/exist/for-code-queue-skills-test", + requiredSkills: ["docs-spec", "cli-spec"], +}); +assertCondition(missing.ok === false, "missing target should fail"); +assertCondition(missing.degraded === true, "missing target should be degraded"); +assertCondition(missing.blocker === "skills-target-missing", "missing target should expose blocker", missing); +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"); + +assertCondition(runtimePreflight.includes("skills: SkillAvailabilityReport"), "runtime preflight type must include skills 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(indexSource.includes("const skillsReady = skills.ok === true"), "dev-ready must gate on structured skills ok"); + +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", + "misspelled skills paths are only surfaced as a forbidden diagnostic risk", + ], + observedRunner: { + source: available.source, + target: available.target, + ok: available.ok, + missingSkills: available.missingSkills, + valuesPrinted: available.valuesPrinted, + }, +}, null, 2)}\n`); diff --git a/scripts/src/check.ts b/scripts/src/check.ts index b000f2e8..784b8bbf 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -302,6 +302,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/src/code-queue-liveness-fixtures.ts"), fileItem("scripts/code-queue-trace-summary-contract-test.ts"), fileItem("scripts/code-queue-pr-preflight-contract-test.ts"), + fileItem("scripts/code-queue-runner-skills-contract-test.ts"), fileItem("scripts/code-queue-submit-routing-contract-test.ts"), fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"), fileItem("scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"), @@ -337,6 +338,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("code-queue:issue3-diagnostics-and-image-preflight", ["bun", "scripts/code-queue-issue3-regression-test.ts"], 30_000)); items.push(commandItem("code-queue:trace-summary-contract", ["bun", "scripts/code-queue-trace-summary-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000)); + items.push(commandItem("code-queue:runner-skills-contract", ["bun", "scripts/code-queue-runner-skills-contract-test.ts"], 30_000)); items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000)); items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000)); items.push(commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000)); @@ -361,6 +363,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("code-queue:issue3-diagnostics-and-image-preflight", "Code Queue issue #3 regression fixtures are opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("code-queue:runner-skills-contract", "Code Queue runner skill availability contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("host-codex-commander:no-daemon-smoke-contract", "host Codex commander no-daemon smoke contract is opt-in with script checks", "--scripts-typecheck or --full")); diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 82391f51..a1f70626 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -2343,13 +2343,21 @@ function compactSkillsStatus(value: unknown): Record | null { const record = asRecord(value); if (record === null) return null; return { + ok: record.ok ?? false, path: record.path ?? null, + source: record.source ?? null, + target: record.target ?? null, mountPoint: record.mountPoint ?? null, exists: record.exists ?? false, available: record.available ?? false, + degraded: record.degraded ?? true, + blocker: record.blocker ?? null, readonly: record.readonly ?? false, skillCount: record.skillCount ?? 0, - cliSpecAvailable: record.cliSpecAvailable ?? false, + requiredSkills: Array.isArray(record.requiredSkills) ? record.requiredSkills : [], + missingSkills: Array.isArray(record.missingSkills) ? record.missingSkills : [], + valuesPrinted: record.valuesPrinted ?? false, + pathSpelling: record.pathSpelling ?? null, repairHint: record.repairHint ?? null, }; } diff --git a/src/components/microservices/code-queue/src/index.ts b/src/components/microservices/code-queue/src/index.ts index 970f3f69..9f76af4c 100644 --- a/src/components/microservices/code-queue/src/index.ts +++ b/src/components/microservices/code-queue/src/index.ts @@ -134,6 +134,7 @@ import { readOaTraceStepsForTask, } from "./oa-events"; import { collectRuntimePreflight, runtimePreflightJson } from "./runtime-preflight"; +import { collectSkillAvailability, skillAvailabilityJson } from "./skill-availability"; import { configureSelfTests, runJudgeInfraSelfTest, runQueueClaimMoveSelfTest, runQueueOrderingSelfTest, runReferenceInjectionSelfTest, runTracePortSelfTest, runTraceSummaryContractSelfTest } from "./self-tests"; import { codexToolLifecycleStartedBeforeIn, @@ -2420,72 +2421,8 @@ function runProbe(command: string, args: string[], timeout = 3_000): { ok: boole return { ok: result.status === 0, output }; } -function decodeMountInfoPath(value: string): string { - return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8))); -} - -function mountInfoForPath(path: string): { mountPoint: string | null; readonly: boolean | null } { - try { - const target = resolve(path); - let best: { mountPoint: string; readonly: boolean } | null = null; - for (const line of readFileSync("/proc/self/mountinfo", "utf8").split(/\r?\n/u)) { - if (line.trim().length === 0) continue; - const fields = line.split(" "); - const mountPoint = decodeMountInfoPath(fields[4] ?? ""); - const options = (fields[5] ?? "").split(","); - if (mountPoint.length === 0) continue; - const matches = target === mountPoint || target.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`); - if (!matches) continue; - if (best === null || mountPoint.length > best.mountPoint.length) best = { mountPoint, readonly: options.includes("ro") }; - } - return best ?? { mountPoint: null, readonly: null }; - } catch { - return { mountPoint: null, readonly: null }; - } -} - function collectSkillsStatus(): JsonValue { - const path = config.skillsPath; - const exists = existsSync(path); - const mountInfo = mountInfoForPath(path); - let directory = false; - let skillCount = 0; - let cliSpecAvailable = false; - let readonly = mountInfo.readonly === true; - let error: string | null = null; - if (exists) { - try { - const stat = statSync(path); - directory = stat.isDirectory(); - if (directory) { - const entries = readdirSync(path, { withFileTypes: true }); - skillCount = entries.filter((entry) => entry.isDirectory()).length; - cliSpecAvailable = existsSync(resolve(path, "cli-spec", "SKILL.md")); - if (mountInfo.readonly === null) { - const writeProbe = runProbe("sh", ["-lc", `test ! -w ${shellQuote(path)}`], 2_000); - readonly = writeProbe.ok; - } - } - } catch (probeError) { - error = probeError instanceof Error ? probeError.message : String(probeError); - } - } - const available = exists && directory; - return { - path, - mountPoint: mountInfo.mountPoint, - exists, - directory, - available, - readonly, - skillCount, - cliSpecAvailable, - expectedMount: "host ~/.agents/skills mounted read-only to UNIDESK_SKILLS_PATH", - repairHint: available && readonly && cliSpecAvailable - ? null - : "DEV code-queue should mount /home/ubuntu/.agents/skills read-only at /root/.agents/skills and set UNIDESK_SKILLS_PATH=/root/.agents/skills.", - error, - } as unknown as JsonValue; + return skillAvailabilityJson(collectSkillAvailability({ target: config.skillsPath })); } function collectDevReady(): JsonValue { @@ -2534,7 +2471,7 @@ function collectDevReady(): JsonValue { const githubKnownHostProbe = runProbe("ssh-keygen", ["-F", "github.com", "-f", "/root/.ssh/known_hosts"]); const sshSharedReady = existsSync("/root/.ssh") && sshKeyProbe.ok && sshKeyProbe.output.trim().length > 0; const skills = collectSkillsStatus() as Record; - const skillsReady = skills.available === true && skills.readonly === true && skills.cliSpecAvailable === true; + 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 value: JsonValue = { diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts index b861ccae..1bd4d6fb 100644 --- a/src/components/microservices/code-queue/src/runtime-preflight.ts +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import type { JsonValue } from "./types"; +import { collectSkillAvailability, type SkillAvailabilityReport } from "./skill-availability"; export type RuntimePreflightAgentPort = "codex" | "opencode"; @@ -51,6 +52,7 @@ export interface RuntimePreflightReport { version: string; }; path: string; + skills: SkillAvailabilityReport; ports: Record; pullRequestDelivery: PullRequestDeliveryPreflight; } @@ -645,13 +647,14 @@ function opencodeStatus(path: string, checkedAt: string): RuntimePreflightPortSt export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): RuntimePreflightReport { const checkedAt = new Date().toISOString(); const path = process.env.PATH ?? ""; + const skills = collectSkillAvailability({ 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: ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, + ok: skills.ok && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok, checkedAt, cwd: process.cwd(), pid: process.pid, @@ -661,6 +664,7 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}): version: process.version, }, path, + skills, ports, pullRequestDelivery, }; diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts new file mode 100644 index 00000000..0d9d9ecd --- /dev/null +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -0,0 +1,188 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import type { JsonValue } from "./types"; + +export interface SkillAvailabilityOptions { + target: string; + source?: string; + requiredSkills?: string[]; +} + +export interface SkillAvailabilityReport { + ok: boolean; + available: boolean; + degraded: boolean; + blocker: string | null; + checkedAt: string; + source: string; + target: string; + path: string; + mountPoint: string | null; + exists: boolean; + directory: boolean; + readonly: boolean; + skillCount: number; + requiredSkills: string[]; + missingSkills: string[]; + skills: Array<{ name: string; present: boolean; skillMdPresent: boolean; path: string }>; + pathSpelling: { + expectedTarget: string; + forbiddenPathChecked: true; + forbiddenPathExists: boolean; + forbiddenPathMustNotBeUsed: true; + }; + expectedMount: string; + repairHint: string | null; + error: string | null; + valuesPrinted: false; +} + +const defaultRequiredSkills = ["docs-spec", "cli-spec", "frontend-design", "playwright-cli"]; +const defaultSource = "/home/ubuntu/.agents/skills"; +const expectedTarget = "/root/.agents/skills"; +const forbiddenSkillsDirName = [".ag", "nets"].join(""); +const forbiddenTargets = [`/root/${forbiddenSkillsDirName}/skills`, `/home/ubuntu/${forbiddenSkillsDirName}/skills`]; +const skillDirectoryAliases: Record = { + "playwright-cli": ["playwright-cli", "playwright"], +}; + +function shellQuote(value: string): string { + return `'${value.replace(/'/gu, "'\\''")}'`; +} + +function commandOk(command: string, args: string[], timeoutMs = 2000): boolean { + const result = spawnSync(command, args, { + encoding: "utf8", + timeout: timeoutMs, + maxBuffer: 64 * 1024, + shell: false, + }); + return result.error === undefined && result.status === 0; +} + +function decodeMountInfoPath(value: string): string { + return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8))); +} + +function mountInfoForPath(path: string): { mountPoint: string | null; readonly: boolean | null } { + try { + const target = resolve(path); + let best: { mountPoint: string; readonly: boolean } | null = null; + for (const line of readFileSync("/proc/self/mountinfo", "utf8").split(/\r?\n/u)) { + if (line.trim().length === 0) continue; + const fields = line.split(" "); + const mountPoint = decodeMountInfoPath(fields[4] ?? ""); + const options = (fields[5] ?? "").split(","); + if (mountPoint.length === 0) continue; + const matches = target === mountPoint || target.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`); + if (!matches) continue; + if (best === null || mountPoint.length > best.mountPoint.length) best = { mountPoint, readonly: options.includes("ro") }; + } + return best ?? { mountPoint: null, readonly: null }; + } catch { + return { mountPoint: null, readonly: null }; + } +} + +function candidateDirs(name: string): string[] { + return skillDirectoryAliases[name] ?? [name]; +} + +function skillStatus(target: string, requiredSkills: string[]): SkillAvailabilityReport["skills"] { + return requiredSkills.map((name) => { + const candidates = candidateDirs(name).map((candidate) => resolve(target, candidate)); + const existingPath = candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isDirectory()); + const path = existingPath ?? candidates[0] ?? resolve(target, name); + return { + name, + present: existingPath !== undefined, + skillMdPresent: existsSync(resolve(path, "SKILL.md")), + path, + }; + }); +} + +export function collectSkillAvailability(options: SkillAvailabilityOptions): SkillAvailabilityReport { + const target = options.target; + const source = options.source ?? defaultSource; + const requiredSkills = options.requiredSkills ?? defaultRequiredSkills; + const checkedAt = new Date().toISOString(); + const mountInfo = mountInfoForPath(target); + const exists = existsSync(target); + const forbiddenPathExists = forbiddenTargets.some((path) => existsSync(path)); + let directory = false; + let skillCount = 0; + let readonly = mountInfo.readonly === true; + let error: string | null = null; + let skills: SkillAvailabilityReport["skills"] = requiredSkills.map((name) => ({ + name, + present: false, + skillMdPresent: false, + path: resolve(target, name), + })); + + if (exists) { + try { + const stat = statSync(target); + directory = stat.isDirectory(); + if (directory) { + const entries = readdirSync(target, { withFileTypes: true }); + skillCount = entries.filter((entry) => entry.isDirectory()).length; + skills = skillStatus(target, requiredSkills); + if (mountInfo.readonly === null) { + readonly = commandOk("sh", ["-lc", `test ! -w ${shellQuote(target)}`]); + } + } + } catch (probeError) { + error = probeError instanceof Error ? probeError.message : String(probeError); + } + } + + const missingSkills = skills.filter((skill) => !skill.present || !skill.skillMdPresent).map((skill) => skill.name); + const available = exists && directory; + const blocker = !available + ? "skills-target-missing" + : !readonly + ? "skills-target-not-readonly" + : missingSkills.length > 0 + ? "required-skills-missing" + : forbiddenPathExists + ? "forbidden-skills-path-present" + : null; + const ok = blocker === null; + return { + ok, + available, + degraded: !ok, + blocker, + checkedAt, + source, + target, + path: target, + mountPoint: mountInfo.mountPoint, + exists, + directory, + readonly, + skillCount, + requiredSkills, + missingSkills, + skills, + pathSpelling: { + expectedTarget, + forbiddenPathChecked: true, + forbiddenPathExists, + forbiddenPathMustNotBeUsed: true, + }, + expectedMount: `${source} mounted read-only to ${target}`, + repairHint: ok + ? null + : `Mount ${source} read-only at ${target}, set UNIDESK_SKILLS_PATH=${target}, and remove any forbidden skills path spelling.`, + error, + valuesPrinted: false, + }; +} + +export function skillAvailabilityJson(report: SkillAvailabilityReport): JsonValue { + return report as unknown as JsonValue; +} diff --git a/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml b/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml index b7ddee3c..450f803c 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/code-queue.k8s.yaml @@ -80,6 +80,8 @@ spec: value: "/var/lib/unidesk/code-queue/opencode-xdg" - name: CODE_QUEUE_SOURCE_CODEX_CONFIG value: "/root/.codex/config.toml" + - name: UNIDESK_SKILLS_PATH + value: "/root/.agents/skills" - name: CODE_QUEUE_DEFAULT_MODEL value: "gpt-5.5" - name: CODE_QUEUE_MODELS @@ -177,6 +179,9 @@ spec: - name: codex-auth mountPath: /root/.codex/auth.json readOnly: true + - name: skills-dir + mountPath: /root/.agents/skills + readOnly: true - name: ssh-dir mountPath: /root/.ssh readOnly: true @@ -232,6 +237,10 @@ spec: hostPath: path: /home/ubuntu/.codex/auth.json type: File + - name: skills-dir + hostPath: + path: /home/ubuntu/.agents/skills + type: Directory - name: ssh-dir hostPath: path: /home/ubuntu/.ssh @@ -317,6 +326,8 @@ spec: value: "/var/lib/unidesk/code-queue/opencode-xdg" - name: CODE_QUEUE_SOURCE_CODEX_CONFIG value: "/root/.codex/config.toml" + - name: UNIDESK_SKILLS_PATH + value: "/root/.agents/skills" - name: CODE_QUEUE_DEFAULT_MODEL value: "gpt-5.5" - name: CODE_QUEUE_MODELS @@ -414,6 +425,9 @@ spec: - name: codex-auth mountPath: /root/.codex/auth.json readOnly: true + - name: skills-dir + mountPath: /root/.agents/skills + readOnly: true - name: ssh-dir mountPath: /root/.ssh readOnly: true @@ -469,6 +483,10 @@ spec: hostPath: path: /home/ubuntu/.codex/auth.json type: File + - name: skills-dir + hostPath: + path: /home/ubuntu/.agents/skills + type: Directory - name: ssh-dir hostPath: path: /home/ubuntu/.ssh @@ -1013,6 +1031,8 @@ spec: value: "/var/lib/unidesk/code-queue/opencode-xdg" - name: CODE_QUEUE_SOURCE_CODEX_CONFIG value: "/root/.codex/config.toml" + - name: UNIDESK_SKILLS_PATH + value: "/root/.agents/skills" - name: CODE_QUEUE_DEFAULT_MODEL value: "gpt-5.5" - name: CODE_QUEUE_MODELS @@ -1110,6 +1130,9 @@ spec: - name: codex-auth mountPath: /root/.codex/auth.json readOnly: true + - name: skills-dir + mountPath: /root/.agents/skills + readOnly: true - name: ssh-dir mountPath: /root/.ssh readOnly: true @@ -1165,6 +1188,10 @@ spec: hostPath: path: /home/ubuntu/.codex/auth.json type: File + - name: skills-dir + hostPath: + path: /home/ubuntu/.agents/skills + type: Directory - name: ssh-dir hostPath: path: /home/ubuntu/.ssh