fix(code-queue): add runner skills sync contract
This commit is contained in:
@@ -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