Merge pull request #163 from pikasTech/fix/codequeue-runner-skills-mount-runtime

Fix Code Queue runner skills target mount
This commit is contained in:
Lyon
2026-05-24 07:29:01 +08:00
committed by GitHub
7 changed files with 131 additions and 56 deletions
+37 -1
View File
@@ -237,7 +237,43 @@
{
"id": "code-queue",
"repo": "https://github.com/pikasTech/unidesk",
"commitId": "0cf73d817f14032ad6038fd47ec402c87bf059bb"
"commitId": "e62f1c21d43a58f73f70516920814ca90f994df8",
"artifact": {
"kind": "source-build",
"repository": "unidesk/code-queue",
"tag": "commitId"
},
"consumer": {
"kind": "d601-k3s-managed",
"dev": {
"enabled": true
},
"prod": {
"enabled": false
},
"supportLevel": "reviewed",
"targetRef": "origin/master:deploy.json#environments.dev.services.code-queue",
"noRuntimeSourceBuild": true,
"target": {
"namespace": "unidesk-dev",
"deployment": "code-queue-scheduler-dev",
"service": "code-queue-scheduler-dev",
"containerName": "code-queue",
"stableImage": "unidesk-code-queue:dev",
"manifestRepoPath": "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml"
}
},
"runtime": {
"containerPort": 4222,
"healthPath": "/health",
"memory": {
"request": "384Mi",
"limit": "5Gi"
},
"health": {
"deployMetadataRequired": true
}
}
}
]
}
+4 -4
View File
@@ -40,14 +40,14 @@ Phase one extends the desired-state model in this order:
4. Add deployment-time metadata that must be common across CI, dev CD and prod CD: deploy env prefix, health deploy-metadata requirement, required runtime secret key names, rollback policy, service port, health path, and resource profile identifiers. The planned first keys are `runtime.containerPort`, `runtime.healthPath`, `runtime.memory.request`, `runtime.memory.limit`, `health.deployMetadataRequired` and `runtime.requiredSecretKeys`. Secret values, credentials, volumes and host paths do not move into `deploy.json`.
5. Teach CI producer dry-run, deploy plan and artifact-registry dry-run to render from `deploy.json` first and compare mirrored `CI.json`/`config.json`/manifest values as derived copies. A mismatch is drift, not an alternate source of truth.
Phase two starts with low-risk D601 k3s user-service artifact consumers: `dev/mdtodo` and `dev/decision-center`. Their `deploy.json` entries now own the source-build artifact repository/tag policy, D601 k3s consumer target, stable image, manifest reference, service port, health path, memory request/limit and health deploy-metadata requirement. `deploy plan --env dev --service <mdtodo|decision-center>` and dry-run artifact consumer plans must read those fields from `deploy.json`; the k8s manifest and artifact-registry executor constants are derived mirrors checked by a structured `deploy-json-drift` preflight.
Phase two starts with low-risk D601 k3s user-service artifact consumers plus the dev-only Code Queue artifact consumer: `dev/mdtodo`, `dev/decision-center` and `dev/code-queue`. Their `deploy.json` entries now own the source-build artifact repository/tag policy, D601 k3s consumer target, stable image, manifest reference, service port, health path, memory request/limit and health deploy-metadata requirement. `deploy plan --env dev --service <mdtodo|decision-center|code-queue>` and dry-run artifact consumer plans must read those fields from `deploy.json`; the k8s manifest and artifact-registry executor constants are derived mirrors checked by a structured `deploy-json-drift` preflight.
Fields that stay outside `deploy.json` during phase one:
- `CI.json`: producer pipeline name, source root, Dockerfile path and success summary shape. Once artifact identity is in `deploy.json`, `CI.json.artifacts[].image.repository` becomes a compatibility mirror checked for drift.
- `config.json`: provider id, proxy route policy, Compose file/service/container names for services not yet migrated, development SSH/worktree details, and secret source locations. For `dev/mdtodo` and `dev/decision-center`, port/health target values in dry-run are deploy-owned and config is no longer an alternate source.
- Compose and Kubernetes manifests: volumes, PVCs, security context and rollout strategy remain concrete manifests. For `dev/mdtodo` and `dev/decision-center`, container port, memory request/limit and deploy metadata env presence are deploy-owned values and manifest copies are drift-checked mirrors until a renderer owns the file.
- Artifact-registry executor code: low-level pull/import/retag/recreate commands, registry probes and platform-specific verification scripts. For `dev/mdtodo` and `dev/decision-center`, executor dry-run consumes the deploy-owned artifact/consumer/runtime contract; any hardcoded mismatch returns `deploy-json-drift`.
- `config.json`: provider id, proxy route policy, Compose file/service/container names for services not yet migrated, development SSH/worktree details, and secret source locations. For `dev/mdtodo`, `dev/decision-center` and `dev/code-queue`, port/health target values in dry-run are deploy-owned and config is no longer an alternate source.
- Compose and Kubernetes manifests: volumes, PVCs, security context and rollout strategy remain concrete manifests. For `dev/mdtodo`, `dev/decision-center` and `dev/code-queue`, container port, memory request/limit and deploy metadata env presence are deploy-owned values and manifest copies are drift-checked mirrors until a renderer owns the file.
- Artifact-registry executor code: low-level pull/import/retag/recreate commands, registry probes and platform-specific verification scripts. For `dev/mdtodo`, `dev/decision-center` and `dev/code-queue`, executor dry-run consumes the deploy-owned artifact/consumer/runtime contract; any hardcoded mismatch returns `deploy-json-drift`.
The drift contract is:
@@ -1,6 +1,7 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { collectSkillAvailability, collectSkillSyncPreflight } from "../src/components/microservices/code-queue/src/skill-availability";
import { codexPrPreflightQueryForTest } from "./src/code-queue";
import { summarizeMicroserviceObservation } from "./src/microservices";
@@ -20,6 +21,19 @@ function countOccurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
function gitShowText(commit: string, path: string): string {
const result = spawnSync("git", ["show", `${commit}:${path}`], {
cwd: process.cwd(),
encoding: "utf8",
maxBuffer: 2 * 1024 * 1024,
});
assertCondition(result.status === 0, `git show should read ${path} at ${commit}`, {
status: result.status,
stderr: result.stderr.slice(-1000),
});
return result.stdout;
}
function createSkillSet(root: string, skills: string[]): void {
for (const skill of skills) {
const dir = join(root, skill);
@@ -30,6 +44,13 @@ function createSkillSet(root: string, skills: string[]): void {
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 deployJson = JSON.parse(readFileSync("deploy.json", "utf8")) as {
environments?: {
dev?: {
services?: Array<Record<string, unknown>>;
};
};
};
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");
@@ -61,6 +82,31 @@ assertCondition(countOccurrences(productionManifest, "name: skills-dir") >= 6, "
});
assertCondition(devManifest.includes("path: /home/ubuntu/.agents/skills"), "dev manifest should keep the same hostPath source of truth");
const devCodeQueueDeploy = (deployJson.environments?.dev?.services ?? []).find((service) => service.id === "code-queue");
assertCondition(devCodeQueueDeploy !== undefined, "deploy.json dev environment must include code-queue");
const devCodeQueueCommit = String(devCodeQueueDeploy?.commitId ?? "");
assertCondition(/^[0-9a-f]{40}$/u.test(devCodeQueueCommit), "deploy.json dev code-queue commit must be a full SHA", devCodeQueueDeploy);
assertCondition(devCodeQueueCommit !== "0cf73d817f14032ad6038fd47ec402c87bf059bb", "deploy.json dev code-queue must not pin the pre-skills-mount source commit", devCodeQueueDeploy);
assertCondition(asRecord(devCodeQueueDeploy?.artifact, "dev code-queue artifact").repository === "unidesk/code-queue", "deploy.json dev code-queue must own artifact repository", devCodeQueueDeploy);
const devCodeQueueConsumer = asRecord(devCodeQueueDeploy?.consumer, "dev code-queue consumer");
assertCondition(devCodeQueueConsumer.kind === "d601-k3s-managed", "deploy.json dev code-queue must use the D601 k3s artifact consumer", devCodeQueueConsumer);
const devCodeQueueTarget = asRecord(devCodeQueueConsumer.target, "dev code-queue consumer target");
assertCondition(devCodeQueueTarget.manifestRepoPath === "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml", "deploy.json dev code-queue must point at the dev k3s manifest", devCodeQueueTarget);
const pinnedDevManifest = gitShowText(devCodeQueueCommit, "src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml");
const pinnedRuntimePreflight = gitShowText(devCodeQueueCommit, "src/components/microservices/code-queue/src/runtime-preflight.ts");
const pinnedIndexSource = gitShowText(devCodeQueueCommit, "src/components/microservices/code-queue/src/index.ts");
assertCondition(countOccurrences(pinnedDevManifest, "path: /home/ubuntu/.agents/skills") === 3, "deploy.json dev code-queue commit must include source skills hostPath for scheduler/read/write", {
commit: devCodeQueueCommit,
});
assertCondition(countOccurrences(pinnedDevManifest, "mountPath: /root/.agents/skills") === 3, "deploy.json dev code-queue commit must mount skills target for scheduler/read/write", {
commit: devCodeQueueCommit,
});
assertCondition(!pinnedDevManifest.includes(forbiddenPathLiteral), "deploy.json dev code-queue commit must not include the misspelled skills path");
assertCondition(pinnedRuntimePreflight.includes("skills.contractOk && ports.codex.ok"), "deploy.json dev code-queue commit runtime-preflight must require target projection contract");
assertCondition(pinnedIndexSource.includes("skills.contractOk === true"), "deploy.json dev code-queue commit dev-ready must require target projection contract");
assertCondition(pinnedIndexSource.includes("return config.skillsPath"), "deploy.json dev code-queue commit must keep runner UNIDESK_SKILLS_PATH on the configured target");
const available = collectSkillAvailability({
source: "/home/ubuntu/.agents/skills",
target: "/home/ubuntu/.agents/skills",
@@ -88,22 +134,22 @@ mkdirSync(fixtureSource, { recursive: true });
createSkillSet(fixtureSource, ["docs-spec", "cli-spec"]);
symlinkSync(fixtureSource, fixtureSymlinkTarget, "dir");
const sourceFallback = collectSkillAvailability({
const missingTargetWithSource = collectSkillAvailability({
source: fixtureSource,
target: fixtureMissingTarget,
requiredSkills: ["docs-spec", "cli-spec"],
});
assertCondition(sourceFallback.ok === true, "source exists target missing should keep runner usable", sourceFallback);
assertCondition(sourceFallback.runnerUsable === true, "source fallback should mark runner usable", sourceFallback);
assertCondition(sourceFallback.contractOk === false, "source fallback should still mark target projection contract degraded", sourceFallback);
assertCondition(sourceFallback.degraded === true, "source fallback should remain degraded for host rollout", sourceFallback);
assertCondition(sourceFallback.blocker === "skills-target-missing", "source fallback should preserve target missing degraded reason", sourceFallback);
assertCondition(sourceFallback.degradedReason === "skills-target-missing", "source fallback should expose bounded degraded reason", sourceFallback);
assertCondition(sourceFallback.resolvedPath === fixtureSource, "source fallback should resolve to source path", sourceFallback);
assertCondition(sourceFallback.resolvedPathSource === "source-fallback", "source fallback should expose resolved path source", sourceFallback);
assertCondition(sourceFallback.skillCount === 2 && sourceFallback.sourceSkillCount === 2 && sourceFallback.targetSkillCount === 0, "source fallback should expose bounded counts", sourceFallback);
assertCondition(asRecord(sourceFallback.resolution, "sourceFallback.resolution").runnerEnvValue === fixtureSource, "source fallback should pass resolved path to runner env", sourceFallback.resolution);
assertCondition(asRecord(sourceFallback.resolution, "sourceFallback.resolution").hostRolloutRequired === true, "source fallback should require host rollout repair", sourceFallback.resolution);
assertCondition(missingTargetWithSource.ok === false, "source exists target missing must keep runner unavailable until target is projected", missingTargetWithSource);
assertCondition(missingTargetWithSource.runnerUsable === false, "source must not be passed as the runner skills path when target is missing", missingTargetWithSource);
assertCondition(missingTargetWithSource.contractOk === false, "missing target must mark target projection contract degraded", missingTargetWithSource);
assertCondition(missingTargetWithSource.degraded === true, "missing target should remain degraded for host rollout", missingTargetWithSource);
assertCondition(missingTargetWithSource.blocker === "skills-target-missing", "missing target should preserve target missing degraded reason", missingTargetWithSource);
assertCondition(missingTargetWithSource.degradedReason === "skills-target-missing", "missing target should expose bounded degraded reason", missingTargetWithSource);
assertCondition(missingTargetWithSource.resolvedPath === fixtureMissingTarget, "missing target should keep runner path at the expected target", missingTargetWithSource);
assertCondition(missingTargetWithSource.resolvedPathSource === "missing", "missing target should not expose source fallback resolution", missingTargetWithSource);
assertCondition(missingTargetWithSource.skillCount === 0 && missingTargetWithSource.sourceSkillCount === 2 && missingTargetWithSource.targetSkillCount === 0, "missing target should expose bounded source and target counts", missingTargetWithSource);
assertCondition(asRecord(missingTargetWithSource.resolution, "missingTargetWithSource.resolution").runnerEnvValue === fixtureMissingTarget, "missing target should not pass source path to runner env", missingTargetWithSource.resolution);
assertCondition(asRecord(missingTargetWithSource.resolution, "missingTargetWithSource.resolution").hostRolloutRequired === true, "missing target should require host rollout repair", missingTargetWithSource.resolution);
const symlinkOk = collectSkillAvailability({
source: fixtureSource,
@@ -139,14 +185,14 @@ const missing = collectSkillAvailability({
target: "/path/that/does/not/exist/for-code-queue-skills-test",
requiredSkills: ["docs-spec", "cli-spec"],
});
assertCondition(missing.ok === true, "approved source should keep missing-target runner usable");
assertCondition(missing.runnerUsable === true, "missing target with approved source should expose runner usable");
assertCondition(missing.ok === false, "approved source must not keep missing-target runner usable");
assertCondition(missing.runnerUsable === false, "missing target with approved source should expose runner unavailable");
assertCondition(missing.contractOk === false, "missing target with approved source should expose hostPath contract degraded");
assertCondition(missing.degraded === true, "missing target should be degraded");
assertCondition(missing.blocker === "skills-target-missing", "missing target should expose blocker", missing);
assertCondition(missing.targetMissingSkills.includes("docs-spec") && missing.targetMissingSkills.includes("cli-spec"), "missing target should list target missing skills", missing);
assertCondition(missing.resolvedPath === "/home/ubuntu/.agents/skills", "missing target should resolve to approved source", missing);
assertCondition(missing.resolvedPathSource === "source-fallback", "missing target should expose source fallback", missing);
assertCondition(missing.resolvedPath === "/path/that/does/not/exist/for-code-queue-skills-test", "missing target should keep the configured target path", missing);
assertCondition(missing.resolvedPathSource === "missing", "missing target should not expose source fallback", missing);
assertCondition(missing.valuesPrinted === false, "missing report must also declare valuesPrinted=false");
const typoTarget = collectSkillAvailability({
@@ -203,8 +249,8 @@ assertCondition(runtimePreflight.includes("skills: SkillAvailabilityReport"), "r
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("collectSkillSyncPreflight"), "runtime preflight must collect skills sync preflight");
assertCondition(runtimePreflight.includes("skills.runnerUsable && ports.codex.ok"), "runtime preflight ok must depend on runner usable skills without blocking on host rollout contract drift");
assertCondition(indexSource.includes("skills.runnerUsable === true"), "dev-ready must gate on structured runner usable skills");
assertCondition(runtimePreflight.includes("skills.contractOk && ports.codex.ok"), "runtime preflight ok must depend on the read-only target projection contract");
assertCondition(indexSource.includes("skills.contractOk === true"), "dev-ready must gate on structured target projection contract");
assertCondition(indexSource.includes("resolvedRunnerSkillsPath"), "runtime must pass resolved skills path to code agents");
assertCondition(indexSource.includes("runnerSkillsBlocker"), "scheduler must check skills before starting code agents");
assertCondition(indexSource.includes("task_blocked_by_runner_skills"), "scheduler must emit structured runner skills blockers");
@@ -278,7 +324,7 @@ const skillsPreflightTransport = {
}),
};
const defaultPreflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote"], skillsPreflightTransport), "default preflight summary");
assertCondition(defaultPreflightSummary.failureKind !== "runner-skills-blocker", "source fallback should not classify as runner skills blocker", defaultPreflightSummary);
assertCondition(defaultPreflightSummary.failureKind === "runner-skills-blocker", "missing target should classify as runner skills blocker even when source exists", defaultPreflightSummary);
assertCondition(asRecord(defaultPreflightSummary.skillsContract, "defaultPreflightSummary.skillsContract").hostRolloutRequired === true, "default preflight should expose host rollout blocker separately", defaultPreflightSummary);
assertCondition(defaultPreflightSummary.preflight === undefined, "default PR preflight should omit detailed preflight internals", defaultPreflightSummary);
assertCondition(asRecord(defaultPreflightSummary.disclosure, "defaultPreflightSummary.disclosure").fullDetailOmitted === true, "default PR preflight should disclose full detail omission", defaultPreflightSummary.disclosure);
@@ -288,11 +334,11 @@ const preflightSummary = asRecord(codexPrPreflightQueryForTest(["--remote", "--f
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", "full preflight should keep source fallback out of runner blocker classification", preflightSummary);
assertCondition(preflightSummary.failureKind === "runner-skills-blocker", "full preflight should classify missing target as runner blocker", preflightSummary);
assertCondition(asRecord(preflightSummary.skillsContract, "preflightSummary.skillsContract").degradedReason === "skills-target-missing", "full preflight should expose target missing as contract degraded reason", preflightSummary);
assertCondition(preflightSkills.target === "/path/that/does/not/exist/for-code-queue-skills-test", "full preflight must show skills target", preflightSkills);
assertCondition(preflightSkills.resolvedPath === "/home/ubuntu/.agents/skills", "full preflight must show resolved source fallback path", preflightSkills);
assertCondition(preflightSkills.resolvedPathSource === "source-fallback", "full preflight must show source fallback resolution", preflightSkills);
assertCondition(preflightSkills.resolvedPath === "/path/that/does/not/exist/for-code-queue-skills-test", "full preflight must keep resolved path at the target", preflightSkills);
assertCondition(preflightSkills.resolvedPathSource === "missing", "full preflight must not show source fallback resolution", preflightSkills);
assertCondition(preflightSkillsSync.dryRun === true && preflightSkillsSync.mutation === false, "full preflight must show non-mutating skills sync dry-run", preflightSkillsSync);
assertCondition(asRecord(preflightSkillsSync.counts, "preflight.skillsSync.counts").missingTargetSkills === 2, "full preflight must show missing target count", preflightSkillsSync);
assertCondition(asRecord(preflightSkillsSync.plannedActions, "preflight.skillsSync.plannedActions").copy === false, "full preflight must show no copy action", preflightSkillsSync);
@@ -400,10 +446,11 @@ process.stdout.write(`${JSON.stringify({
ok: true,
checks: [
"production Code Queue mounts /home/ubuntu/.agents/skills read-only at /root/.agents/skills",
"deploy.json dev Code Queue pins a commit whose manifest and runtime require the target skills projection",
"skill availability report exposes source, target, requiredSkills, missingSkills, version fingerprint/mtime, degraded/blocker and valuesPrinted=false",
"skills sync dry-run reports source, target, counts, version fingerprint/mtime, missing skills, permission failures, instructions and no-copy actions",
"scheduler blocks runner startup with structured infra-blocked output when required skills are unavailable",
"runtime-preflight, dev-ready, health and PR preflight use the same structured skill and sync reports",
"runtime-preflight, dev-ready, health and PR preflight require the target projection instead of source fallback",
"default health/preflight summaries expose bounded skills lifecycle evidence and --full expansion",
"misspelled skills paths are rejected with forbidden-skills-path-configured before generic missing/unapproved path blockers",
],
+1 -1
View File
@@ -59,7 +59,7 @@ const plannedDeployJsonFields = [
"runtime.requiredSecretKeys",
];
const phaseTwoExecutorContractServices = new Set(["dev/decision-center", "dev/mdtodo"]);
const phaseTwoExecutorContractServices = new Set(["dev/decision-center", "dev/mdtodo", "dev/code-queue"]);
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
@@ -2478,12 +2478,12 @@ function collectSkillsSyncPreflight(): JsonValue {
}
function resolvedRunnerSkillsPath(): string {
return currentSkillAvailability().resolution.runnerEnvValue;
return config.skillsPath;
}
function runnerSkillsBlocker(): Record<string, JsonValue> | null {
const skills = currentSkillAvailability();
if (skills.runnerUsable) return null;
if (skills.contractOk) return null;
const pathSpelling = {
expectedTarget: skills.pathSpelling.expectedTarget,
forbiddenPathChecked: skills.pathSpelling.forbiddenPathChecked,
@@ -2565,7 +2565,7 @@ function collectDevReady(): JsonValue {
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 || skills.runnerUsable === true;
const skillsReady = skills.contractOk === 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 = {
@@ -656,7 +656,7 @@ export function collectRuntimePreflight(options: RuntimePreflightOptions = {}):
};
const pullRequestDelivery = collectPullRequestDeliveryPreflight(options, checkedAt);
return {
ok: skills.runnerUsable && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok,
ok: skills.contractOk && ports.codex.ok && ports.opencode.ok && pullRequestDelivery.ok,
checkedAt,
cwd: process.cwd(),
pid: process.pid,
@@ -438,41 +438,33 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski
const sourceProbe = collectSyncPathReport(source, source === defaultSource, requiredSkills).report;
const targetProbe = collectSyncPathReport(target, target === expectedTarget || normalizedPathText(target) === normalizedPathText(source), requiredSkills).report;
const targetSymlinkToSource = targetProbe.symlink && sameResolvedPath(targetProbe.realPath, sourceProbe.realPath);
const targetHasRequiredSkills = reportHasRequiredSkills(targetProbe);
const sourceHasRequiredSkills = reportHasRequiredSkills(sourceProbe);
const targetHasRequiredSkills = reportHasRequiredSkills(targetProbe);
const targetReadonlyOk = targetProbe.readonly || targetSymlinkToSource || normalizedPathText(target) === normalizedPathText(source);
const targetReady = targetHasRequiredSkills && targetReadonlyOk;
const targetBlocker = targetHasRequiredSkills && targetSymlinkToSource ? null : targetBlockerFor(targetProbe);
const sourceBlocker = sourceBlockerFor(sourceProbe);
const missingBoth = !targetProbe.exists && !sourceProbe.exists;
const resolutionSource: SkillAvailabilityReport["resolvedPathSource"] = forbiddenPathConfigured || missingBoth
const resolutionSource: SkillAvailabilityReport["resolvedPathSource"] = forbiddenPathConfigured || !targetReady
? "missing"
: targetReady
? targetSymlinkToSource ? "target-symlink" : "target"
: sourceHasRequiredSkills
? "source-fallback"
: "missing";
const resolvedPath = resolutionSource === "target" || resolutionSource === "target-symlink"
? target
: resolutionSource === "source-fallback"
? source
: target;
const selectedReport = resolutionSource === "source-fallback" ? sourceProbe : targetProbe;
const runnerUsable = !forbiddenPathConfigured && resolutionSource !== "missing";
const contractOk = runnerUsable && resolutionSource !== "source-fallback" && targetReady && !forbiddenPathExists;
: targetSymlinkToSource ? "target-symlink" : "target";
const resolvedPath = target;
const selectedReport = targetProbe;
const runnerUsable = !forbiddenPathConfigured && targetReady && !forbiddenPathExists;
const contractOk = runnerUsable;
const blocker = forbiddenPathConfigured
? "forbidden-skills-path-configured"
: forbiddenPathExists
? "forbidden-skills-path-present"
: missingBoth
? "skills-source-and-target-missing"
: !runnerUsable
? targetBlocker ?? sourceBlocker ?? "required-skills-missing"
: contractOk
? null
: targetBlocker ?? (forbiddenPathExists ? "forbidden-skills-path-present" : null);
: null;
const degradedReason = contractOk ? null : blocker;
const ok = runnerUsable;
const ok = contractOk;
const missingSkills = selectedReport.missingSkills;
const skills = resolutionSource === "source-fallback" ? sourceProbe.skills : targetProbe.skills;
const skills = targetProbe.skills;
return {
ok,
runnerUsable,
@@ -497,7 +489,7 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski
passesToRunnerEnv: true,
targetBlocker,
degradedReason,
hostRolloutRequired: runnerUsable && !contractOk,
hostRolloutRequired: sourceHasRequiredSkills && !contractOk,
},
mountPoint: selectedReport.mountPoint,
exists: targetProbe.exists,
@@ -549,8 +541,8 @@ export function collectSkillAvailability(options: SkillAvailabilityOptions): Ski
},
repairHint: contractOk
? null
: runnerUsable
? `Runner can use ${resolvedPath}; restore the read-only ${defaultSource} -> ${expectedTarget} projection so host rollout no longer reports ${degradedReason ?? "skills contract degraded"}.`
: sourceHasRequiredSkills
? `Source ${defaultSource} has the required skills; restore the read-only ${defaultSource} -> ${expectedTarget} projection before starting runners.`
: `Mount ${defaultSource} read-only at ${expectedTarget}, set UNIDESK_SKILLS_PATH=${expectedTarget}, and remove any forbidden skills path spelling.`,
error: selectedReport.error,
valuesPrinted: false,