fix(code-queue): add runner skills sync contract
This commit is contained in:
@@ -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 <issue-number>
|
||||
```
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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`);
|
||||
|
||||
+180
-12
@@ -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<string, unknown> | null {
|
||||
};
|
||||
}
|
||||
|
||||
function compactSkillPathReport(value: unknown): Record<string, unknown> | 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<string, unknown> | null {
|
||||
const record = asRecord(value);
|
||||
if (record === null) return null;
|
||||
const source = compactSkillPathReport(record.source);
|
||||
const target = compactSkillPathReport(record.target);
|
||||
const compact: Record<string, unknown> = {
|
||||
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<string, unknown>, options: CodexPrPreflightOptions): Record<string, unknown> {
|
||||
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<string, unknown>, 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<string, unknown>, 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");
|
||||
}
|
||||
|
||||
+5
-3
@@ -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 <commitId> [--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/<name>] [--pr-create-dry-run --pr-create-dry-run-head <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/<name>] [--pr-create-dry-run --pr-create-dry-run-head <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 <taskId> [--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 <taskId> [--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 <commitId> # disabled legacy deployment entry",
|
||||
@@ -256,6 +257,7 @@ function codexHelp(): unknown {
|
||||
"bun scripts/cli.ts codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]",
|
||||
"bun scripts/cli.ts codex read <taskId>",
|
||||
"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/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N]",
|
||||
"bun scripts/cli.ts codex judge <taskId> --attempt N [--dry-run] [--include-prompt]",
|
||||
"bun scripts/cli.ts codex steer <taskId> [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 <taskId> --full", "codex task <taskId> --trace --limit N", "codex output <taskId> --after-seq N --limit N --full-text", "codex tasks --view full --limit N"],
|
||||
expand: ["codex task <taskId> --full", "codex task <taskId> --trace --limit N", "codex output <taskId> --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.",
|
||||
};
|
||||
|
||||
@@ -367,6 +367,86 @@ function compactQueueHealth(value: unknown): Record<string, unknown> | null {
|
||||
};
|
||||
}
|
||||
|
||||
function compactSkillAvailability(value: unknown): Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | null {
|
||||
const record = asRecord(body);
|
||||
if (record === null) return null;
|
||||
@@ -388,6 +468,8 @@ function compactMicroserviceBody(body: unknown, serviceId: string): Record<strin
|
||||
taskCount: record.taskCount ?? null,
|
||||
schemaReady: record.schemaReady ?? null,
|
||||
queue: compactQueueHealth(record.queue),
|
||||
skills: compactSkillAvailability(record.skills),
|
||||
skillsSync: compactSkillSync(record.skillsSync),
|
||||
resourceBudget: compactRecordFields(asRecord(record.resourceBudget), [
|
||||
"targetMemoryMb",
|
||||
"mgrPoolMax",
|
||||
@@ -401,7 +483,8 @@ function compactMicroserviceBody(body: unknown, serviceId: string): Record<strin
|
||||
devReady: devReady === null ? null : {
|
||||
ok: devReady.ok ?? null,
|
||||
missingTools: compactStringList(devReady.missingTools, 8),
|
||||
skills: devReady.skills ?? null,
|
||||
skills: compactSkillAvailability(devReady.skills),
|
||||
skillsSync: compactSkillSync(devReady.skillsSync),
|
||||
},
|
||||
executionDiagnostics: diagnostics === null ? null : {
|
||||
state: diagnostics.state ?? null,
|
||||
@@ -416,7 +499,7 @@ function compactMicroserviceBody(body: unknown, serviceId: string): Record<strin
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMicroserviceObservation(action: string, serviceId: string, response: unknown, args: string[]): unknown {
|
||||
export function summarizeMicroserviceObservation(action: string, serviceId: string, response: unknown, args: string[]): unknown {
|
||||
if (hasFlag(args, "--full") || hasFlag(args, "--raw")) return response;
|
||||
const record = asRecord(response);
|
||||
if (record === null) return response;
|
||||
|
||||
@@ -134,7 +134,7 @@ import {
|
||||
readOaTraceStepsForTask,
|
||||
} from "./oa-events";
|
||||
import { collectRuntimePreflight, runtimePreflightJson } from "./runtime-preflight";
|
||||
import { collectSkillAvailability, skillAvailabilityJson } from "./skill-availability";
|
||||
import { collectSkillAvailability, collectSkillSyncPreflight, skillAvailabilityJson, skillSyncPreflightJson } from "./skill-availability";
|
||||
import { configureSelfTests, runJudgeInfraSelfTest, runQueueClaimMoveSelfTest, runQueueOrderingSelfTest, runReferenceInjectionSelfTest, runTracePortSelfTest, runTraceSummaryContractSelfTest } from "./self-tests";
|
||||
import {
|
||||
codexToolLifecycleStartedBeforeIn,
|
||||
@@ -2425,6 +2425,10 @@ function collectSkillsStatus(): JsonValue {
|
||||
return skillAvailabilityJson(collectSkillAvailability({ target: config.skillsPath }));
|
||||
}
|
||||
|
||||
function collectSkillsSyncPreflight(): JsonValue {
|
||||
return skillSyncPreflightJson(collectSkillSyncPreflight({ target: config.skillsPath }));
|
||||
}
|
||||
|
||||
function collectDevReady(): JsonValue {
|
||||
const now = Date.now();
|
||||
if (devReadyCache !== null && now - devReadyCache.checkedAtMs < 30_000) return devReadyCache.value;
|
||||
@@ -2471,9 +2475,10 @@ 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 skillsSync = collectSkillsSyncPreflight() as Record<string, JsonValue>;
|
||||
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<Response> {
|
||||
databaseLastError,
|
||||
modelProviderConfig: codeModelProviderSourceContract(config),
|
||||
skills: collectSkillsStatus(),
|
||||
skillsSync: collectSkillsSyncPreflight(),
|
||||
startedAt: serviceStartedAt,
|
||||
}, 503);
|
||||
return jsonResponse({
|
||||
@@ -5213,6 +5220,7 @@ async function route(req: Request): Promise<Response> {
|
||||
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<Response> {
|
||||
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,
|
||||
|
||||
@@ -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<RuntimePreflightAgentPort, RuntimePreflightPortStatus>;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user