fix(code-queue): preflight runner skills availability
This commit is contained in:
@@ -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<string, JsonValue>;
|
||||
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 = {
|
||||
|
||||
@@ -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<RuntimePreflightAgentPort, RuntimePreflightPortStatus>;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
"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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user