Fix Code Queue runner skills delivery

This commit is contained in:
Codex
2026-05-23 19:56:01 +00:00
parent fccd25b31e
commit 9b164c1781
9 changed files with 402 additions and 8 deletions
+214 -4
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { posix as posixPath } from "node:path";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, posix as posixPath } from "node:path";
import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "./ci-catalog";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
@@ -63,8 +64,11 @@ interface CiPublishUserServiceArtifactOptions {
dockerfile: string;
imageRepository: string;
dryRun: boolean;
transport: CiPublishTransport;
}
type CiPublishTransport = "auto" | "tekton" | "direct-docker";
interface CiDevE2EOptions {
repoUrl: string;
desiredRef: string;
@@ -232,6 +236,12 @@ function boolFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function publishTransportOption(value: string | null): CiPublishTransport {
if (value === null || value === "auto") return "auto";
if (value === "tekton" || value === "direct-docker") return value;
throw new Error("ci publish-user-service --transport must be one of: auto, tekton, direct-docker");
}
function isHelpArg(value: string | undefined): boolean {
return value === "help" || value === "--help" || value === "-h";
}
@@ -1478,6 +1488,195 @@ function missingArtifactSummaryFields(artifact: ArtifactSummary): string[] {
return missing;
}
function dockerArtifactDigest(repository: string, imageRef: string): string | null {
const inspect = runCommand(["docker", "image", "inspect", imageRef, "--format", "{{range .RepoDigests}}{{println .}}{{end}}"], repoRoot, { timeoutMs: 30_000 });
if (inspect.exitCode !== 0) return null;
for (const line of inspect.stdout.split(/\r?\n/u).map((item) => item.trim()).filter(Boolean)) {
const [repo, digest] = line.split("@");
if (repo === repository && /^sha256:[0-9a-f]{64}$/u.test(digest ?? "")) return digest ?? null;
}
return null;
}
function registryManifestDigest(repository: string, tag: string): string | null {
const registry = "127.0.0.1:5000";
if (!repository.startsWith(`${registry}/`)) return null;
const repositoryPath = repository.slice(`${registry}/`.length);
if (repositoryPath.length === 0 || repositoryPath.includes("..") || tag.length === 0) return null;
const result = runCommand([
"curl",
"-fsSI",
"-H",
"Accept: application/vnd.docker.distribution.manifest.v2+json",
`http://${registry}/v2/${repositoryPath}/manifests/${tag}`,
], repoRoot, { timeoutMs: 30_000 });
if (result.exitCode !== 0) return null;
const match = /^Docker-Content-Digest:\s*(sha256:[0-9a-f]{64})\s*$/imu.exec(result.stdout);
return match?.[1] ?? null;
}
function assertCommandOk(result: ReturnType<typeof runCommand>, label: string): void {
if (result.exitCode === 0 && !result.timedOut) return;
throw new Error(`${label} failed: ${result.stderr.slice(-2000) || result.stdout.slice(-2000) || `exitCode=${result.exitCode}`}`);
}
function buildContextForService(serviceId: string, dockerfile: string): string {
return serviceId === "claudeqq" ? posixPath.dirname(dockerfile) : ".";
}
function directDockerSourceGitMode(repoUrl: string): "local-unidesk-worktree" | "git-archive" {
return repoUrl === "https://github.com/pikasTech/unidesk" ? "local-unidesk-worktree" : "git-archive";
}
async function prepareDirectDockerUserServiceSource(options: CiPublishUserServiceArtifactOptions): Promise<{ path: string; cleanup: () => void; summary: Record<string, unknown> }> {
const tempRoot = mkdtempSync(join(tmpdir(), `unidesk-ci-${options.serviceId}-${options.commit.slice(0, 8)}-`));
const sourcePath = join(tempRoot, "source");
const gitMode = directDockerSourceGitMode(options.repoUrl);
if (gitMode !== "local-unidesk-worktree") {
rmSync(tempRoot, { recursive: true, force: true });
throw new Error("direct-docker publish currently supports only UniDesk repo-owned source-build artifacts; use --transport tekton for external repositories");
}
const resolved = runCommand(["git", "rev-parse", "--verify", `${options.commit}^{commit}`], repoRoot, { timeoutMs: 30_000 });
assertCommandOk(resolved, "resolve source commit");
if (resolved.stdout.trim() !== options.commit) {
rmSync(tempRoot, { recursive: true, force: true });
throw new Error(`direct-docker source commit mismatch: resolved ${resolved.stdout.trim()} expected ${options.commit}`);
}
const dockerfileExists = runCommand(["git", "cat-file", "-e", `${options.commit}:${options.dockerfile}`], repoRoot, { timeoutMs: 30_000 });
assertCommandOk(dockerfileExists, "verify source dockerfile");
const worktree = runCommand(["git", "worktree", "add", "--detach", sourcePath, options.commit], repoRoot, { timeoutMs: 120_000 });
if (worktree.exitCode !== 0) {
rmSync(tempRoot, { recursive: true, force: true });
throw new Error(`prepare source worktree failed: ${worktree.stderr || worktree.stdout}`);
}
return {
path: sourcePath,
cleanup: () => {
const removed = runCommand(["git", "worktree", "remove", "--force", sourcePath], repoRoot, { timeoutMs: 60_000 });
if (removed.exitCode !== 0) rmSync(tempRoot, { recursive: true, force: true });
else rmSync(tempRoot, { recursive: true, force: true });
},
summary: {
ok: true,
mode: gitMode,
providerId: "local",
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
dockerfile: options.dockerfile,
sourceHostPath: sourcePath,
valuesPrinted: false,
},
};
}
async function publishUserServiceArtifactDirectDocker(options: CiPublishUserServiceArtifactOptions, context: ArtifactSummaryContext): Promise<Record<string, unknown>> {
const source = await prepareDirectDockerUserServiceSource(options);
const planned = artifactSummaryDefaults(context);
const localImage = `${options.imageRepository}:${options.commit}`;
const buildContext = buildContextForService(options.serviceId, options.dockerfile);
const baseArgs = options.serviceId === "code-queue" && runCommand(["docker", "image", "inspect", "unidesk-code-queue:d601"], repoRoot, { timeoutMs: 30_000 }).exitCode === 0
? ["--build-arg", "CODE_QUEUE_BASE_IMAGE=unidesk-code-queue:d601"]
: [];
try {
if (options.serviceId === "code-queue" && baseArgs.length === 0) {
return {
ok: false,
runnerDisposition: "infra-blocked",
failureClassification: "ci-runner-not-ready",
failureKind: "code-queue-base-image-missing",
serviceId: options.serviceId,
commit: options.commit,
artifactSummary: planned,
source: source.summary,
artifact: planned.imageRef,
transport: "direct-docker",
controlledPublish: {
environment: "DEV-local-artifact",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --transport direct-docker`,
noRollout: true,
noRuntimeMutation: true,
},
boundary: "direct-docker CI artifact publish builds and pushes a commit-pinned image only; no deploy apply, no rollout, no Code Queue restart, no active task mutation",
next: [
"Restore or warm local unidesk-code-queue:d601 base image, then rerun the same direct-docker publish command.",
],
};
}
const build = runCommand([
"docker",
"build",
"--label",
`unidesk.ai/service-id=${options.serviceId}`,
"--label",
`unidesk.ai/source-repo=${options.repoUrl}`,
"--label",
`unidesk.ai/source-commit=${options.commit}`,
"--label",
`unidesk.ai/dockerfile=${options.dockerfile}`,
...baseArgs,
"-t",
localImage,
"-t",
planned.imageRef,
"-f",
options.dockerfile,
buildContext,
], source.path, { timeoutMs: Math.max(options.waitMs, 20 * 60_000) });
assertCommandOk(build, "direct docker build");
const inspectLabels = runCommand(["docker", "image", "inspect", planned.imageRef, "--format", "{{ index .Config.Labels \"unidesk.ai/service-id\" }} {{ index .Config.Labels \"unidesk.ai/source-commit\" }}"], repoRoot, { timeoutMs: 30_000 });
assertCommandOk(inspectLabels, "inspect built image labels");
if (!inspectLabels.stdout.includes(`${options.serviceId} ${options.commit}`)) throw new Error(`direct docker image labels did not match ${options.serviceId}/${options.commit}`);
const registryCheck = runCommand(["curl", "-fsS", "http://127.0.0.1:5000/v2/"], repoRoot, { timeoutMs: 15_000 });
assertCommandOk(registryCheck, "D601 loopback artifact registry health");
const push = runCommand(["docker", "push", planned.imageRef], repoRoot, { timeoutMs: Math.max(options.waitMs, 10 * 60_000) });
assertCommandOk(push, "direct docker push");
const pull = runCommand(["docker", "pull", planned.imageRef], repoRoot, { timeoutMs: 120_000 });
assertCommandOk(pull, "direct docker pull verification");
const digest = dockerArtifactDigest(planned.repository, planned.imageRef) ?? registryManifestDigest(planned.repository, planned.tag);
const artifact: ArtifactSummary = {
...planned,
digest,
digestRef: digest === null ? null : `${planned.repository}@${digest}`,
};
assertArtifactSummaryComplete(artifact, "direct-docker");
return {
ok: true,
transport: "direct-docker",
pipelineRun: null,
namespace: null,
repoUrl: options.repoUrl,
commit: options.commit,
serviceId: options.serviceId,
source: source.summary,
artifact: artifact.imageRef,
artifactSummary: artifact,
controlledPublish: {
environment: "DEV-local-artifact",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --transport direct-docker`,
noRollout: true,
noRuntimeMutation: true,
dependsOnLocalUnideskDatabase: false,
},
boundary: "direct-docker CI artifact publish builds and pushes a commit-pinned image only; no deploy apply, no rollout, no Code Queue restart, no active task mutation",
wait: {
ok: true,
dispatchOk: null,
dispatchStatus: null,
dispatchExitCode: null,
stdoutTail: push.stdout.slice(-6000),
stderrTail: push.stderr.slice(-6000),
},
condition: null,
next: [
"use artifactSummary.imageRef or artifactSummary.digestRef as later dev artifact consumer input",
],
};
} finally {
source.cleanup();
}
}
function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void {
const missing = missingArtifactSummaryFields(artifact);
if (missing.length > 0) {
@@ -1862,13 +2061,16 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-user-service-artifact-publish",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
requiresReadyControlChannels: publishPreflightControlChannelOrder,
},
boundary: preflight.boundary,
next: preflight.next,
};
}
if (options.transport === "direct-docker" || (options.transport === "auto" && options.serviceId === "code-queue" && options.repoUrl === "https://github.com/pikasTech/unidesk")) {
return publishUserServiceArtifactDirectDocker(options, summaryContext);
}
const source = options.serviceId === "claudeqq"
? await prepareClaudeqqArtifactSource(config, options)
: await prepareUserServiceArtifactSource(config, options);
@@ -1948,6 +2150,7 @@ export async function runCiPublishUserServiceDryRunPreflight(
imageRepository: artifact.image.repository,
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
dryRun: true,
transport: publishTransportOption(stringOption(args, "--transport")),
};
const preflight = await publishUserServicePreflight(config, options, plannedArtifact, transport);
const plannedRepoFetchUrl = repoSshUrl(options.repoUrl);
@@ -1992,7 +2195,7 @@ export async function runCiPublishUserServiceDryRunPreflight(
environment: "D601",
namespace: "unidesk-ci",
pipeline: "unidesk-user-service-artifact-publish",
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000`,
command: `bun scripts/cli.ts ci publish-user-service --service ${options.serviceId} --commit ${options.commit} --wait-ms 1200000 --transport ${options.transport}`,
requiresReadyControlChannels: publishPreflightControlChannelOrder,
},
boundary: preflight.boundary,
@@ -2422,6 +2625,11 @@ export function ciHelp(): Record<string, unknown> {
userServiceArtifact: {
producer: "D601 CI",
command: "bun scripts/cli.ts ci publish-user-service --service <service-id> --commit <full-sha>",
transports: {
auto: "uses direct-docker for repo-owned code-queue artifacts and Tekton for the remaining services",
tekton: "D601 Tekton PipelineRun through backend-core/provider control plane",
directDocker: "repo-owned Docker artifact publish without backend-core/database dispatch; no deploy apply, rollout, restart, or active-task mutation",
},
supportedServices: supportedSourceBuildArtifactIds().filter((serviceId) => serviceId !== "backend-core"),
blockedServices: blockedCatalogArtifactIds(),
registry: "127.0.0.1:5000/unidesk/<service-id>:<commit>",
@@ -2486,6 +2694,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision"));
const waitMs = numberOption(args, "--wait-ms", 0);
const dryRun = boolFlag(args, "--dry-run");
const transport = publishTransportOption(stringOption(args, "--transport"));
if (stringOption(args, "--repo") !== null || stringOption(args, "--repo-url") !== null) {
throw new Error("ci publish-user-service reads source repo from CI.json; edit CI.json instead of using --repo");
}
@@ -2512,6 +2721,7 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi
imageRepository: artifact.image.repository,
sourceHostPath: userServiceArtifactSourceHostPath(serviceId, commit),
dryRun,
transport,
});
}
if (action === "run-dev-e2e") {
+3
View File
@@ -6207,6 +6207,7 @@ function compactSkillsStatus(value: unknown): Record<string, unknown> | null {
degradedReason: record.degradedReason ?? record.blocker ?? null,
readonly: record.readonly ?? false,
skillCount: record.skillCount ?? 0,
version: record.version ?? null,
sourceSkillCount: record.sourceSkillCount ?? null,
targetSkillCount: record.targetSkillCount ?? null,
sourceMissingSkills: Array.isArray(record.sourceMissingSkills) ? record.sourceMissingSkills.map(String) : [],
@@ -6241,6 +6242,7 @@ function compactSkillPathReport(value: unknown): Record<string, unknown> | null
symlink: record.symlink ?? false,
realPath: record.realPath ?? null,
skillCount: record.skillCount ?? 0,
version: record.version ?? null,
requiredSkills: Array.isArray(record.requiredSkills) ? record.requiredSkills.map(String) : [],
missingSkills: Array.isArray(record.missingSkills) ? record.missingSkills.map(String) : [],
error: record.error ?? null,
@@ -6265,6 +6267,7 @@ function compactSkillsSyncStatus(value: unknown, full = false): Record<string, u
target,
expected: record.expected ?? null,
counts: record.counts ?? null,
version: record.version ?? 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,
+4
View File
@@ -642,6 +642,7 @@ function compactSkillAvailability(value: unknown): Record<string, unknown> | nul
degradedReason: skills.degradedReason ?? skills.blocker ?? null,
readonly: skills.readonly ?? false,
skillCount: skills.skillCount ?? 0,
version: skills.version ?? null,
sourceSkillCount: skills.sourceSkillCount ?? null,
targetSkillCount: skills.targetSkillCount ?? null,
sourceMissingSkills: Array.isArray(skills.sourceMissingSkills) ? skills.sourceMissingSkills.map(String) : [],
@@ -676,6 +677,7 @@ function compactSkillSync(value: unknown): Record<string, unknown> | null {
"symlink",
"realPath",
"skillCount",
"version",
"requiredSkills",
"missingSkills",
"error",
@@ -692,6 +694,7 @@ function compactSkillSync(value: unknown): Record<string, unknown> | null {
"symlink",
"realPath",
"skillCount",
"version",
"requiredSkills",
"missingSkills",
"error",
@@ -708,6 +711,7 @@ function compactSkillSync(value: unknown): Record<string, unknown> | null {
source,
target,
counts: sync.counts ?? null,
version: sync.version ?? null,
missing: sync.missing ?? null,
permissionFailureCount: permissionFailures.length,
permissionFailures: permissionFailures.slice(0, 4),