diff --git a/scripts/cli.ts b/scripts/cli.ts index 80269d5c..32049883 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -374,7 +374,10 @@ async function main(): Promise { return; } if (sub === "start") { - emitJson(commandName, startStack(config)); + const result = startStack(config); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; return; } if (sub === "stop") { @@ -382,7 +385,10 @@ async function main(): Promise { return; } if (sub === "status") { - emitJson(commandName, await stackStatus(config)); + const result = await stackStatus(config); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; return; } if (sub === "swap") { diff --git a/scripts/d601-host-compose-status-contract-test.ts b/scripts/d601-host-compose-status-contract-test.ts new file mode 100644 index 00000000..df328d88 --- /dev/null +++ b/scripts/d601-host-compose-status-contract-test.ts @@ -0,0 +1,48 @@ +import { readConfig } from "./src/config"; +import { deprecatedD601HostComposeEntryForTest, type ComposeRuntimeEnv, type ContainerStatus } from "./src/docker"; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +const runtimeEnv: ComposeRuntimeEnv = { + envFile: "/home/ubuntu/workspace/unidesk-dev/.state/docker-compose.env", + logDir: "not-created-on-deprecated-d601-host-compose-entry", + logDay: "20260610", + logPrefix: "20260610_120000", +}; + +const config = readConfig(); + +const deprecated = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/home/ubuntu/workspace/unidesk-dev"); +assertCondition(deprecated?.ok === false, "D601 host compose entry should be a controlled failure", deprecated); +assertCondition(deprecated.error === "deprecated-host-compose-entry", "D601 host compose entry should expose the deprecation classifier", deprecated); +assertCondition(deprecated.runnerDisposition === "business-failed", "D601 host compose entry should be classified as business-failed", deprecated); +assertCondition(deprecated.decision.hostComposeRetained === false, "D601 host compose entry should be explicitly deprecated", deprecated.decision); +assertCondition(deprecated.correctEntrypoints.d601NativeK3s.includes("trans D601:k3s"), "D601 status should point to native k3s as the D601 acceptance entry", deprecated.correctEntrypoints); +assertCondition(deprecated.correctEntrypoints.mainServerStatus.includes("/root/unidesk"), "D601 status should point main-server compose checks at /root/unidesk", deprecated.correctEntrypoints); +assertCondition(deprecated.evidence.logDir === runtimeEnv.logDir, "D601 status should preserve repo-local runtime evidence", deprecated.evidence); +assertCondition(!JSON.stringify(deprecated).includes("/workspace/unidesk/logs"), "D601 status should not surface the stale /workspace/unidesk/logs permission path", deprecated); +assertCondition(deprecated.evidence.conflictingListeners.every((item) => item.expected === "ignored-on-deprecated-d601-host-compose-entry"), "D601 status should mark occupied public ports as ignored for this deprecated entry", deprecated.evidence.conflictingListeners); + +const legacyOperatorPath = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/home/ubuntu/unidesk"); +assertCondition(legacyOperatorPath?.error === "deprecated-host-compose-entry", "legacy D601 operator checkout should receive the same deprecation classifier", legacyOperatorPath); + +const staleWorkspacePath = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/workspace/unidesk"); +assertCondition(staleWorkspacePath?.error === "deprecated-host-compose-entry", "stale /workspace D601 checkout should be classified before log directory writes", staleWorkspacePath); +assertCondition(!JSON.stringify(staleWorkspacePath).includes("/workspace/unidesk/logs"), "stale /workspace D601 checkout should not surface the stale log permission path", staleWorkspacePath); + +const canonicalMainServer = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [], "/root/unidesk"); +assertCondition(canonicalMainServer === null, "canonical main-server checkout should keep the normal compose path", canonicalMainServer); + +const existingContainer: ContainerStatus = { + id: "abc123", + name: "unidesk-backend-core", + image: "unidesk/backend-core:test", + status: "Up", + ports: "", +}; +const activeCompose = deprecatedD601HostComposeEntryForTest(config, runtimeEnv, [existingContainer], "/home/ubuntu/workspace/unidesk-dev"); +assertCondition(activeCompose === null, "D601 checkout with compose containers should keep the normal compose path", activeCompose); + +console.log(JSON.stringify({ ok: true, contract: "d601-host-compose-status" }, null, 2)); diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index eb6da45d..b19cda6b 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -20,6 +20,29 @@ export interface ContainerStatus { ports: string; } +export interface DeprecatedHostComposeEntry { + ok: false; + error: "deprecated-host-compose-entry"; + runnerDisposition: "business-failed"; + hostRole: "d601-provider-host"; + composeProject: string; + cwd: string; + message: string; + decision: { + hostComposeRetained: false; + disposition: "deprecated"; + reason: string; + }; + evidence: { + containers: ContainerStatus[]; + publicPorts: Array<{ name: string; port: number; listening: boolean }>; + conflictingListeners: Array<{ name: string; port: number; listening: boolean; expected: string }>; + logDir: string; + }; + correctEntrypoints: Record; + references: string[]; +} + const rebuildableServices = ["backend-core", "frontend", "dev-frontend-proxy", "provider-gateway", "todo-note", "project-manager", "baidu-netdisk", "oa-event-flow", "code-queue-mgr"] as const; export type RebuildableService = typeof rebuildableServices[number]; @@ -103,6 +126,16 @@ function envValue(value: string): string { return JSON.stringify(value); } +function composeRuntimeEnvPreview(config: UniDeskConfig): ComposeRuntimeEnv { + const parts = localDateParts(new Date()); + return { + envFile: join(rootPath(config.paths.stateDir), "docker-compose.env"), + logDir: "not-created-on-deprecated-d601-host-compose-entry", + logDay: parts.day, + logPrefix: parts.stamp, + }; +} + export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): ComposeRuntimeEnv { const stateDir = rootPath(config.paths.stateDir); mkdirSync(stateDir, { recursive: true }); @@ -290,9 +323,11 @@ export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRunti } export function startStack(config: UniDeskConfig): unknown { + const containers = dockerContainers(config); + const deprecatedEntry = deprecatedD601HostComposeEntry(config, composeRuntimeEnvPreview(config), containers); + if (deprecatedEntry !== null) return deprecatedEntry; const runtimeEnv = writeComposeEnv(config, true); const compose = resolveComposeCommand(config, runtimeEnv.envFile); - const containers = dockerContainers(config); const occupiedPorts = fixedPorts(config).filter((item) => item.listening); if (occupiedPorts.length > 0 && containers.length === 0) { throw new Error(`Fixed UniDesk port is occupied before start: ${occupiedPorts.map((p) => `${p.name}:${p.port}`).join(", ")}`); @@ -528,6 +563,9 @@ function dockerExec(config: UniDeskConfig, container: string, command: string[]) } export async function stackStatus(config: UniDeskConfig): Promise { + const containers = dockerContainers(config); + const deprecatedEntry = deprecatedD601HostComposeEntry(config, composeRuntimeEnvPreview(config), containers); + if (deprecatedEntry !== null) return deprecatedEntry; const runtimeEnv = writeComposeEnv(config, false); const runtimeRaw = existsSync(runtimeEnv.envFile) ? readFileSync(runtimeEnv.envFile, "utf8") : ""; const runtimeValue = (key: string): string => runtimeRaw.match(new RegExp(`^${key}=(.*)$`, "m"))?.[1]?.replace(/^"|"$/g, "") ?? ""; @@ -569,7 +607,7 @@ export async function stackStatus(config: UniDeskConfig): Promise { { name: "baidu-netdisk", containerPort: 4244, hostPort: null }, { name: "oa-event-flow", containerPort: 4255, hostPort: null }, ], - containers: dockerContainers(config), + containers, health: { core: coreHealth, frontend: await probe(`http://127.0.0.1:${config.network.frontend.port}/health`), @@ -590,6 +628,58 @@ export async function stackStatus(config: UniDeskConfig): Promise { }; } +export function deprecatedD601HostComposeEntryForTest(config: UniDeskConfig, runtimeEnv: ComposeRuntimeEnv, containers: ContainerStatus[], currentRoot: string): DeprecatedHostComposeEntry | null { + return deprecatedD601HostComposeEntry(config, runtimeEnv, containers, currentRoot); +} + +function deprecatedD601HostComposeEntry(config: UniDeskConfig, runtimeEnv: ComposeRuntimeEnv, containers: ContainerStatus[], currentRoot = repoRoot): DeprecatedHostComposeEntry | null { + const cwd = resolve(currentRoot); + const d601Workspaces = ["/home/ubuntu/unidesk", "/home/ubuntu/workspace/unidesk-dev", "/workspace/unidesk"]; + const cwdLooksD601 = d601Workspaces.some((workspace) => cwd === workspace || cwd.startsWith(`${workspace}/.worktree/`)); + const upgradeRootLooksMainServer = config.providerGateway.upgrade.hostProjectRoot === "/root/unidesk"; + if (!cwdLooksD601 || !upgradeRootLooksMainServer || containers.length > 0) return null; + const publicPorts = fixedPorts(config); + const conflictingListeners = publicPorts + .filter((item) => item.listening) + .map((item) => ({ + name: item.name, + port: item.port, + listening: item.listening, + expected: "ignored-on-deprecated-d601-host-compose-entry", + })); + return { + ok: false, + error: "deprecated-host-compose-entry", + runnerDisposition: "business-failed", + hostRole: "d601-provider-host", + composeProject: config.docker.projectName, + cwd, + message: "D601 host Compose is not the UniDesk server acceptance path. This command is deprecated here and does not start or validate the main-server stack.", + decision: { + hostComposeRetained: false, + disposition: "deprecated", + reason: "D601 owns provider-local Docker services and native k3s workloads; main-server Compose remains rooted at /root/unidesk on the main server.", + }, + evidence: { + containers, + publicPorts, + conflictingListeners, + logDir: runtimeEnv.logDir, + }, + correctEntrypoints: { + mainServerStatus: "run bun scripts/cli.ts server status from /root/unidesk on the main server", + d601NativeK3s: "trans D601:k3s kubectl get deploy,svc,pod,endpoints -n unidesk -o wide", + decisionCenter: "bun scripts/cli.ts microservice health decision-center", + d601DevDeploy: "bun scripts/cli.ts deploy plan --env dev --service ", + }, + references: [ + "docs/reference/deployment.md#docker-compose-runtime", + "docs/reference/dev-environment.md#d601-unidesk-workspace", + "docs/reference/deploy.md#d601-native-k3s-emergency-guard", + ], + }; +} + function listLogFiles(root: string): string[] { if (!existsSync(root)) return []; const entries = readdirSync(root, { withFileTypes: true }); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index ebbbd08f..bbbcc949 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -595,6 +595,90 @@ function artifactRegistryHelp(): unknown { }; } +function agentRunHelpSummary(): unknown { + return { + command: "agentrun v01 aipod-specs|queue|sessions|control-plane|git-mirror", + output: "json", + usage: [ + "bun scripts/cli.ts agentrun v01 aipod-specs show Artificer", + "bun scripts/cli.ts agentrun v01 queue commander --reader-id cli", + "bun scripts/cli.ts agentrun v01 sessions trace --after-seq 0 --limit 100", + "bun scripts/cli.ts agentrun v01 control-plane status", + ], + description: "Operate AgentRun v0.1 AipodSpec, queue, sessions, and G14 control-plane entrypoints.", + }; +} + +function platformInfraHelpSummary(): unknown { + return { + command: "platform-infra sub2api plan|apply|status|validate|codex-pool", + output: "json", + usage: [ + "bun scripts/cli.ts platform-infra sub2api plan", + "bun scripts/cli.ts platform-infra sub2api status [--full|--raw]", + "bun scripts/cli.ts platform-infra sub2api codex-pool validate", + ], + description: "Operate G14 platform-infra services such as Sub2API and the YAML-controlled Codex pool.", + }; +} + +function hwlabNodeHelpSummary(): unknown { + return { + command: "hwlab nodes control-plane|git-mirror|secret --node --lane ", + output: "json", + usage: [ + "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03", + "bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03", + "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name ", + ], + description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data.", + }; +} + +function hwlabG14HelpSummary(): unknown { + return { + command: "hwlab g14 monitor-prs|control-plane|git-mirror|tools-image|retirement", + output: "json", + usage: [ + "bun scripts/cli.ts hwlab g14 control-plane status --lane v02", + "bun scripts/cli.ts hwlab g14 trigger-current --lane v02 --dry-run", + "bun scripts/cli.ts hwlab g14 monitor-prs --status", + ], + description: "Operate the G14 HWLAB runtime lane control-plane and legacy retirement helpers.", + }; +} + +function hwlabHelpSummary(): unknown { + return { + command: "hwlab g14|nodes|cd", + output: "json", + usage: [ + "bun scripts/cli.ts hwlab g14 control-plane status --lane v02", + "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03", + "bun scripts/cli.ts hwlab cd audit --env dev", + ], + description: "HWLAB operations. Current runtime work uses G14 lane commands; D601 cd is legacy diagnostics only.", + }; +} + +function helpFallback(help: unknown, error: unknown): unknown { + if (typeof help !== "object" || help === null || Array.isArray(help)) return help; + return { + ...help, + degraded: true, + degradedReason: "help-module-load-failed", + error: error instanceof Error ? error.message : String(error), + }; +} + +async function loadHelp(loader: () => Promise, fallback: unknown): Promise { + try { + return await loader(); + } catch (error) { + return helpFallback(fallback, error); + } +} + export async function staticNamespaceHelp(args: string[]): Promise { const [top, sub] = args; if (!args.slice(1).some(isHelpToken)) return null; @@ -614,25 +698,10 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./agentrun")).agentRunHelp(), agentRunHelpSummary()); + if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary()); + if (top === "hwlab" && (sub === "node" || sub === "nodes")) return loadHelp(async () => (await import("./hwlab-node")).hwlabNodeHelp(), hwlabNodeHelpSummary()); + if (top === "hwlab" && sub === "g14") return loadHelp(async () => (await import("./hwlab-g14")).hwlabG14Help(), hwlabG14HelpSummary()); + if (top === "hwlab") return loadHelp(async () => (await import("./hwlab-cd")).hwlabHelp(), hwlabHelpSummary()); return null; }