From e62f1c21d43a58f73f70516920814ca90f994df8 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 23 May 2026 23:22:56 +0000 Subject: [PATCH 1/2] fix: require code queue runner skills target mount --- .../code-queue-runner-skills-contract-test.ts | 46 +++++++++---------- .../microservices/code-queue/src/index.ts | 6 +-- .../code-queue/src/runtime-preflight.ts | 2 +- .../code-queue/src/skill-availability.ts | 38 ++++++--------- 4 files changed, 42 insertions(+), 50 deletions(-) diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts index 70122335..c15735f5 100644 --- a/scripts/code-queue-runner-skills-contract-test.ts +++ b/scripts/code-queue-runner-skills-contract-test.ts @@ -88,22 +88,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 +139,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 +203,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 +278,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 +288,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); @@ -403,7 +403,7 @@ process.stdout.write(`${JSON.stringify({ "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", ], diff --git a/src/components/microservices/code-queue/src/index.ts b/src/components/microservices/code-queue/src/index.ts index d4c97c81..9a5cc9d7 100644 --- a/src/components/microservices/code-queue/src/index.ts +++ b/src/components/microservices/code-queue/src/index.ts @@ -2478,12 +2478,12 @@ function collectSkillsSyncPreflight(): JsonValue { } function resolvedRunnerSkillsPath(): string { - return currentSkillAvailability().resolution.runnerEnvValue; + return config.skillsPath; } function runnerSkillsBlocker(): Record | 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; const skillsSync = collectSkillsSyncPreflight() as Record; - 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 = { diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts index d9246705..2057ad50 100644 --- a/src/components/microservices/code-queue/src/runtime-preflight.ts +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -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, diff --git a/src/components/microservices/code-queue/src/skill-availability.ts b/src/components/microservices/code-queue/src/skill-availability.ts index 5a9e0d44..f095eac5 100644 --- a/src/components/microservices/code-queue/src/skill-availability.ts +++ b/src/components/microservices/code-queue/src/skill-availability.ts @@ -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, From c34e44b3f7ca22fd3cbd1c3e87c27aea748591fd Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 23 May 2026 23:26:58 +0000 Subject: [PATCH 2/2] fix: pin code queue dev artifact to skills mount runtime --- deploy.json | 38 ++++++++++++++- docs/reference/cicd-standardization.md | 8 ++-- .../code-queue-runner-skills-contract-test.ts | 47 +++++++++++++++++++ scripts/issue-60-cicd-drift-contract-test.ts | 2 +- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/deploy.json b/deploy.json index 383bf83f..b64de6bf 100644 --- a/deploy.json +++ b/deploy.json @@ -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 + } + } } ] } diff --git a/docs/reference/cicd-standardization.md b/docs/reference/cicd-standardization.md index ab326b7b..57be1fc0 100644 --- a/docs/reference/cicd-standardization.md +++ b/docs/reference/cicd-standardization.md @@ -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 ` 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 ` 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: diff --git a/scripts/code-queue-runner-skills-contract-test.ts b/scripts/code-queue-runner-skills-contract-test.ts index c15735f5..868fa4d8 100644 --- a/scripts/code-queue-runner-skills-contract-test.ts +++ b/scripts/code-queue-runner-skills-contract-test.ts @@ -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>; + }; + }; +}; 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", @@ -400,6 +446,7 @@ 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", diff --git a/scripts/issue-60-cicd-drift-contract-test.ts b/scripts/issue-60-cicd-drift-contract-test.ts index 58b84a2b..19a86cf8 100644 --- a/scripts/issue-60-cicd-drift-contract-test.ts +++ b/scripts/issue-60-cicd-drift-contract-test.ts @@ -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)}`);