From 0e9ddfebb90fe0d4520dd3b0b2bfc45bc46c7722 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 08:37:00 +0000 Subject: [PATCH 1/2] chore: record DEV apply for code agent runner --- reports/dev-gate/dev-deploy-report.json | 985 +++++++++++++++++++----- 1 file changed, 812 insertions(+), 173 deletions(-) diff --git a/reports/dev-gate/dev-deploy-report.json b/reports/dev-gate/dev-deploy-report.json index ab7582e4..1daa83bc 100644 --- a/reports/dev-gate/dev-deploy-report.json +++ b/reports/dev-gate/dev-deploy-report.json @@ -4,6 +4,9 @@ "reportVersion": "v1", "issue": "pikasTech/HWLAB#33", "supports": [ + "pikasTech/HWLAB#164", + "pikasTech/HWLAB#143", + "pikasTech/HWLAB#99", "pikasTech/HWLAB#63", "pikasTech/HWLAB#50", "pikasTech/HWLAB#7", @@ -13,12 +16,20 @@ "pikasTech/HWLAB#30" ], "taskId": "dev-deploy-apply", - "commitId": "7e29522", + "commitId": "4d2a0ba", "acceptanceLevel": "dev_deploy_apply", "devOnly": true, "prodDisabled": true, + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current DEV deploy apply report; active public API/health endpoint is 16667 and browser endpoint is 16666." + }, "status": "pass", - "generatedAt": "2026-05-22T05:33:09.590Z", + "generatedAt": "2026-05-23T08:34:34.256Z", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", "sourceContract": { @@ -57,12 +68,17 @@ ], "evidence": [ "artifact services checked: 13", - "expected artifact commit: 7e29522", + "expected artifact commit: 4d2a0ba", "namespace: hwlab-dev", "workloads planned: 13", "kubectl executor: /usr/local/bin/kubectl", + "kubectl kubeconfig source: env:KUBECONFIG", + "kubectl kubeconfig: /var/lib/unidesk/code-queue/kubeconfig", "live health: pass", - "template job replacements: 2" + "code agent provider desired-state: pass", + "code agent provider live env before apply: pass", + "template job replacements: 2", + "legacy simulator deployment cleanups: 0" ], "summary": "DEV dry-run preflight passed." }, @@ -72,29 +88,31 @@ "DEV artifact catalog must contain CI publish evidence and registry digests", "kubectl must be available for D601 hwlab-dev and must not target PROD", "hwlab-cloud-api /health/live must report serviceId, dev environment, and DB env readiness without exposing secret values", + "hwlab-cloud-api must declare and preserve OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key plus the DEV Code Agent egress proxy env without reading Secret values", "pods, services, configmaps, and image commit tags must be observable in hwlab-dev" ], "summary": "DEV deploy preconditions are satisfied." }, "devDeployApply": { - "mode": "dry-run", + "mode": "apply", "conclusion": { "status": "ready", "reason": "All DEV-only preconditions passed; human approval is still required before apply.", "blockerCount": 0 }, "applyBoundary": { - "currentMode": "dry-run", - "defaultNoWrite": true, - "mutationAttempted": false, - "mutationAllowed": false, + "currentMode": "apply-requested", + "defaultNoWrite": false, + "mutationAttempted": true, + "mutationAllowed": true, "applyRequiresFlags": [ "--apply", "--confirm-dev", "--confirmed-non-production" ], - "writeScope": "kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only; delete/recreate is allowed only for explicitly allowlisted suspended DEV template Jobs", - "noWriteScope": "source/catalog/k8s validation, optional DEV health probe, kubectl read, replacement planning, and server-side dry-run only", + "kubeconfigSource": "env:KUBECONFIG", + "writeScope": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only; delete/recreate is allowed only for explicitly allowlisted suspended DEV template Jobs and stale legacy simulator Deployments", + "noWriteScope": "source/catalog/k8s validation, optional DEV health probe, KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl read, replacement planning, and server-side dry-run only", "forbiddenActions": [ "prod-deploy", "secret-read", @@ -106,7 +124,7 @@ "force-push" ] }, - "mutationAttempted": false, + "mutationAttempted": true, "target": { "environment": "dev", "namespace": "hwlab-dev", @@ -114,12 +132,12 @@ "prodDisabled": true }, "artifactPlan": { - "expectedArtifactCommit": "7e29522", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "sourceCommitId": "7e29522", + "expectedArtifactCommit": "4d2a0ba", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "sourceCommitId": "4d2a0ba", "serviceCommitIds": [ - "7e29522" + "4d2a0ba" ], "matchesSourceCommit": true, "published": true, @@ -142,7 +160,7 @@ { "name": "hwlab-cloud-api", "serviceId": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:4d2a0ba" } ] }, @@ -158,7 +176,7 @@ { "name": "hwlab-cloud-web", "serviceId": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba" } ] }, @@ -174,7 +192,7 @@ { "name": "hwlab-agent-mgr", "serviceId": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:4d2a0ba" } ] }, @@ -190,7 +208,7 @@ { "name": "hwlab-agent-worker", "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:4d2a0ba" } ] }, @@ -206,12 +224,12 @@ { "name": "hwlab-gateway", "serviceId": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:4d2a0ba" } ] }, { - "kind": "Deployment", + "kind": "StatefulSet", "name": "hwlab-gateway-simu", "namespace": "hwlab-dev", "replicas": 2, @@ -222,12 +240,12 @@ { "name": "hwlab-gateway-simu", "serviceId": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:4d2a0ba" } ] }, { - "kind": "Deployment", + "kind": "StatefulSet", "name": "hwlab-box-simu", "namespace": "hwlab-dev", "replicas": 2, @@ -238,7 +256,7 @@ { "name": "hwlab-box-simu", "serviceId": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:4d2a0ba" } ] }, @@ -254,7 +272,7 @@ { "name": "hwlab-patch-panel", "serviceId": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:4d2a0ba" } ] }, @@ -270,7 +288,7 @@ { "name": "hwlab-router", "serviceId": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-router:4d2a0ba" } ] }, @@ -286,7 +304,7 @@ { "name": "hwlab-tunnel-client", "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:4d2a0ba" } ] }, @@ -302,7 +320,7 @@ { "name": "hwlab-edge-proxy", "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:4d2a0ba" } ] }, @@ -318,7 +336,7 @@ { "name": "hwlab-cli", "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-cli:4d2a0ba" } ] }, @@ -334,7 +352,7 @@ { "name": "hwlab-agent-skills", "serviceId": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:4d2a0ba" } ] } @@ -354,6 +372,34 @@ } ] }, + { + "kind": "Service", + "name": "hwlab-gateway-simu-1", + "namespace": "hwlab-dev", + "serviceId": "hwlab-gateway-simu", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7101, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-gateway-simu-2", + "namespace": "hwlab-dev", + "serviceId": "hwlab-gateway-simu", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7101, + "targetPort": "http" + } + ] + }, { "kind": "Service", "name": "hwlab-cloud-web", @@ -368,6 +414,34 @@ } ] }, + { + "kind": "Service", + "name": "hwlab-box-simu-1", + "namespace": "hwlab-dev", + "serviceId": "hwlab-box-simu", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7201, + "targetPort": "http" + } + ] + }, + { + "kind": "Service", + "name": "hwlab-box-simu-2", + "namespace": "hwlab-dev", + "serviceId": "hwlab-box-simu", + "type": "ClusterIP", + "ports": [ + { + "name": "http", + "port": 7201, + "targetPort": "http" + } + ] + }, { "kind": "Service", "name": "hwlab-gateway", @@ -534,25 +608,28 @@ "desiredSuspended": true, "liveExists": true, "liveSuspended": true, - "oldImage": "127.0.0.1:5000/hwlab/hwlab-agent-worker:cb35ada", - "newImage": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522", + "oldImage": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2343328", + "newImage": "127.0.0.1:5000/hwlab/hwlab-agent-worker:4d2a0ba", "oldImages": [ { "name": "hwlab-agent-worker", "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:cb35ada" + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2343328" } ], "newImages": [ { "name": "hwlab-agent-worker", "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:4d2a0ba" } ], "replace": true, - "result": "planned", - "reason": "Live suspended template Job image differs from the desired manifest; delete/recreate is required because spec.template is immutable." + "result": "replaced", + "reason": "Live suspended template Job was deleted and recreated by kubectl apply.", + "deleteCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev delete job hwlab-agent-worker-template --ignore-not-found=true", + "deleteStdout": "job.batch \"hwlab-agent-worker-template\" deleted from hwlab-dev namespace", + "deleteStderr": "" }, { "namespace": "hwlab-dev", @@ -561,38 +638,500 @@ "desiredSuspended": true, "liveExists": true, "liveSuspended": true, - "oldImage": "127.0.0.1:5000/hwlab/hwlab-cli:cb35ada", - "newImage": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522", + "oldImage": "127.0.0.1:5000/hwlab/hwlab-cli:2343328", + "newImage": "127.0.0.1:5000/hwlab/hwlab-cli:4d2a0ba", "oldImages": [ { "name": "hwlab-cli", "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:cb35ada" + "image": "127.0.0.1:5000/hwlab/hwlab-cli:2343328" } ], "newImages": [ { "name": "hwlab-cli", "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522" + "image": "127.0.0.1:5000/hwlab/hwlab-cli:4d2a0ba" } ], "replace": true, - "result": "planned", - "reason": "Live suspended template Job image differs from the desired manifest; delete/recreate is required because spec.template is immutable." + "result": "replaced", + "reason": "Live suspended template Job was deleted and recreated by kubectl apply.", + "deleteCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev delete job hwlab-cli-template --ignore-not-found=true", + "deleteStdout": "job.batch \"hwlab-cli-template\" deleted from hwlab-dev namespace", + "deleteStderr": "" } ], + "legacySimulatorDeploymentCleanupPolicy": { + "status": "active", + "namespace": "hwlab-dev", + "scope": "DEV-only stale simulator Deployments replaced by indexed StatefulSets", + "allowedDeployments": [ + { + "name": "hwlab-gateway-simu", + "serviceId": "hwlab-gateway-simu", + "replacementKind": "StatefulSet", + "reason": "M3 DEV requires exactly two indexed gateway-simu identities/sessions" + }, + { + "name": "hwlab-box-simu", + "serviceId": "hwlab-box-simu", + "replacementKind": "StatefulSet", + "reason": "M3 DEV requires exactly two indexed box-simu resource identities" + } + ] + }, + "legacySimulatorDeploymentCleanups": [ + { + "namespace": "hwlab-dev", + "deploymentName": "hwlab-gateway-simu", + "serviceId": "hwlab-gateway-simu", + "allowed": true, + "desiredReplacementExists": true, + "desiredReplacementKind": "StatefulSet", + "liveExists": false, + "oldImage": null, + "newImage": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:4d2a0ba", + "oldImages": [], + "newImages": [ + { + "name": "hwlab-gateway-simu", + "serviceId": "hwlab-gateway-simu", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:4d2a0ba" + } + ], + "cleanup": false, + "result": "not_found", + "reason": "Live legacy Deployment is absent; no cleanup is needed." + }, + { + "namespace": "hwlab-dev", + "deploymentName": "hwlab-box-simu", + "serviceId": "hwlab-box-simu", + "allowed": true, + "desiredReplacementExists": true, + "desiredReplacementKind": "StatefulSet", + "liveExists": false, + "oldImage": null, + "newImage": "127.0.0.1:5000/hwlab/hwlab-box-simu:4d2a0ba", + "oldImages": [], + "newImages": [ + { + "name": "hwlab-box-simu", + "serviceId": "hwlab-box-simu", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:4d2a0ba" + } + ], + "cleanup": false, + "result": "not_found", + "reason": "Live legacy Deployment is absent; no cleanup is needed." + } + ], + "cloudWebRollout": { + "serviceId": "hwlab-cloud-web", + "namespace": "hwlab-dev", + "status": "observed", + "observationPhase": "after_apply", + "sourceCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "imageTag": "4d2a0ba", + "digest": "sha256:fe834adbfd8cf0ca7e514af6c71dfe7243540084f81e6ef124549e4071e1d200", + "repositoryDigest": null, + "publishState": "published", + "rolloutRevision": "30", + "liveImage": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "imageMatchesDesired": true, + "expectedRuntimeEnv": { + "HWLAB_COMMIT_ID": "4d2a0ba", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "HWLAB_IMAGE_TAG": "4d2a0ba" + }, + "liveRuntimeEnv": { + "status": "match", + "matchesDesired": true, + "fields": [ + { + "name": "HWLAB_COMMIT_ID", + "expected": "4d2a0ba", + "actual": "4d2a0ba", + "status": "match" + }, + { + "name": "HWLAB_IMAGE", + "expected": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "actual": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "status": "match" + }, + { + "name": "HWLAB_IMAGE_TAG", + "expected": "4d2a0ba", + "actual": "4d2a0ba", + "status": "match" + } + ], + "drift": [] + }, + "runtimeEnvMatchesDesired": true, + "runtimeEnvDrift": [], + "verificationCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-web --timeout=180s", + "readCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev get deployment hwlab-cloud-web -o json", + "kubeconfig": "/var/lib/unidesk/code-queue/kubeconfig", + "kubeconfigSource": "env:KUBECONFIG" + }, + "cloudWebRolloutBeforeApply": { + "serviceId": "hwlab-cloud-web", + "namespace": "hwlab-dev", + "status": "observed", + "observationPhase": "before_apply", + "sourceCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "imageTag": "4d2a0ba", + "digest": "sha256:fe834adbfd8cf0ca7e514af6c71dfe7243540084f81e6ef124549e4071e1d200", + "repositoryDigest": null, + "publishState": "published", + "rolloutRevision": "29", + "liveImage": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2343328", + "imageMatchesDesired": false, + "expectedRuntimeEnv": { + "HWLAB_COMMIT_ID": "4d2a0ba", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "HWLAB_IMAGE_TAG": "4d2a0ba" + }, + "liveRuntimeEnv": { + "status": "drift", + "matchesDesired": false, + "fields": [ + { + "name": "HWLAB_COMMIT_ID", + "expected": "4d2a0ba", + "actual": "2343328", + "status": "drift" + }, + { + "name": "HWLAB_IMAGE", + "expected": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "actual": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2343328", + "status": "drift" + }, + { + "name": "HWLAB_IMAGE_TAG", + "expected": "4d2a0ba", + "actual": "2343328", + "status": "drift" + } + ], + "drift": [ + { + "name": "HWLAB_COMMIT_ID", + "expected": "4d2a0ba", + "actual": "2343328", + "status": "drift" + }, + { + "name": "HWLAB_IMAGE", + "expected": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "actual": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2343328", + "status": "drift" + }, + { + "name": "HWLAB_IMAGE_TAG", + "expected": "4d2a0ba", + "actual": "2343328", + "status": "drift" + } + ] + }, + "runtimeEnvMatchesDesired": false, + "runtimeEnvDrift": [ + { + "name": "HWLAB_COMMIT_ID", + "expected": "4d2a0ba", + "actual": "2343328", + "status": "drift" + }, + { + "name": "HWLAB_IMAGE", + "expected": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "actual": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2343328", + "status": "drift" + }, + { + "name": "HWLAB_IMAGE_TAG", + "expected": "4d2a0ba", + "actual": "2343328", + "status": "drift" + } + ], + "verificationCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-web --timeout=180s", + "readCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev get deployment hwlab-cloud-web -o json", + "kubeconfig": "/var/lib/unidesk/code-queue/kubeconfig", + "kubeconfigSource": "env:KUBECONFIG" + }, + "cloudWebRolloutAfterApply": { + "serviceId": "hwlab-cloud-web", + "namespace": "hwlab-dev", + "status": "observed", + "observationPhase": "after_apply", + "sourceCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "imageTag": "4d2a0ba", + "digest": "sha256:fe834adbfd8cf0ca7e514af6c71dfe7243540084f81e6ef124549e4071e1d200", + "repositoryDigest": null, + "publishState": "published", + "rolloutRevision": "30", + "liveImage": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "imageMatchesDesired": true, + "expectedRuntimeEnv": { + "HWLAB_COMMIT_ID": "4d2a0ba", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "HWLAB_IMAGE_TAG": "4d2a0ba" + }, + "liveRuntimeEnv": { + "status": "match", + "matchesDesired": true, + "fields": [ + { + "name": "HWLAB_COMMIT_ID", + "expected": "4d2a0ba", + "actual": "4d2a0ba", + "status": "match" + }, + { + "name": "HWLAB_IMAGE", + "expected": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "actual": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "status": "match" + }, + { + "name": "HWLAB_IMAGE_TAG", + "expected": "4d2a0ba", + "actual": "4d2a0ba", + "status": "match" + } + ], + "drift": [] + }, + "runtimeEnvMatchesDesired": true, + "runtimeEnvDrift": [], + "verificationCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-web --timeout=180s", + "readCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev get deployment hwlab-cloud-web -o json", + "kubeconfig": "/var/lib/unidesk/code-queue/kubeconfig", + "kubeconfigSource": "env:KUBECONFIG" + }, + "codeAgentProvider": { + "desiredState": { + "contractVersion": "v1", + "environment": "dev", + "provider": "openai-responses", + "model": "gpt-5.5", + "status": "pass", + "ready": true, + "requiredEnv": [ + { + "name": "HWLAB_CODE_AGENT_PROVIDER", + "deployPresent": true, + "workloadPresent": true, + "redacted": false, + "source": "runtime-env" + }, + { + "name": "HWLAB_CODE_AGENT_MODEL", + "deployPresent": true, + "workloadPresent": true, + "redacted": false, + "source": "runtime-env" + }, + { + "name": "OPENAI_API_KEY", + "deployPresent": true, + "workloadPresent": true, + "redacted": true, + "source": "k8s-secret-ref" + }, + { + "name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "deployPresent": true, + "workloadPresent": true, + "redacted": true, + "source": "runtime-env" + } + ], + "secretRef": { + "env": "OPENAI_API_KEY", + "secretName": "hwlab-code-agent-provider", + "secretKey": "openai-api-key", + "deployPlaceholderPresent": true, + "workloadSecretRefPresent": true, + "present": true, + "redacted": true + }, + "egress": { + "env": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "target": "dev-egress-proxy", + "deployPresent": true, + "workloadPresent": true, + "deployMatchesDevProxy": true, + "workloadMatchesDevProxy": true, + "directPublicOpenAi": false, + "valueRedacted": true + }, + "missingDeployEnv": [], + "missingWorkloadEnv": [], + "deployMismatches": [], + "workloadMismatches": [], + "missingSecretRefs": [], + "missingEgressContract": [], + "secretMaterialRead": false, + "valuesRedacted": true, + "providerConnected": false, + "fixtureEvidence": false + }, + "liveBeforeApply": { + "phase": "before_apply", + "namespace": "hwlab-dev", + "serviceId": "hwlab-cloud-api", + "status": "pass", + "readCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev get deployment hwlab-cloud-api -o json", + "kubeconfig": "/var/lib/unidesk/code-queue/kubeconfig", + "expectedSecretRef": "hwlab-code-agent-provider/openai-api-key", + "expectedEgressTarget": "dev-egress-proxy", + "secretValuesRead": false, + "kubernetesSecretDataRead": false, + "valuesRedacted": true, + "contractVersion": "v1", + "environment": "dev", + "provider": "openai-responses", + "model": "gpt-5.5", + "ready": true, + "requiredEnv": [ + { + "name": "HWLAB_CODE_AGENT_PROVIDER", + "present": true, + "redacted": false, + "source": "runtime-env" + }, + { + "name": "HWLAB_CODE_AGENT_MODEL", + "present": true, + "redacted": false, + "source": "runtime-env" + }, + { + "name": "OPENAI_API_KEY", + "present": true, + "redacted": true, + "source": "k8s-secret-ref" + }, + { + "name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "present": true, + "redacted": true, + "source": "runtime-env" + } + ], + "missingEnv": [], + "mismatchedEnv": [], + "secretRef": { + "env": "OPENAI_API_KEY", + "secretName": "hwlab-code-agent-provider", + "secretKey": "openai-api-key", + "present": true, + "redacted": true + }, + "egress": { + "env": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "target": "dev-egress-proxy", + "present": true, + "matchesDevProxy": true, + "directPublicOpenAi": false, + "valueRedacted": true + }, + "secretMaterialRead": false, + "providerConnected": false, + "deployment": "hwlab-cloud-api", + "container": "hwlab-cloud-api", + "validationScope": "deployment-env-and-secretKeyRef-metadata-only" + }, + "liveAfterApply": { + "phase": "after_apply", + "namespace": "hwlab-dev", + "serviceId": "hwlab-cloud-api", + "status": "pass", + "readCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev get deployment hwlab-cloud-api -o json", + "kubeconfig": "/var/lib/unidesk/code-queue/kubeconfig", + "expectedSecretRef": "hwlab-code-agent-provider/openai-api-key", + "expectedEgressTarget": "dev-egress-proxy", + "secretValuesRead": false, + "kubernetesSecretDataRead": false, + "valuesRedacted": true, + "contractVersion": "v1", + "environment": "dev", + "provider": "openai-responses", + "model": "gpt-5.5", + "ready": true, + "requiredEnv": [ + { + "name": "HWLAB_CODE_AGENT_PROVIDER", + "present": true, + "redacted": false, + "source": "runtime-env" + }, + { + "name": "HWLAB_CODE_AGENT_MODEL", + "present": true, + "redacted": false, + "source": "runtime-env" + }, + { + "name": "OPENAI_API_KEY", + "present": true, + "redacted": true, + "source": "k8s-secret-ref" + }, + { + "name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "present": true, + "redacted": true, + "source": "runtime-env" + } + ], + "missingEnv": [], + "mismatchedEnv": [], + "secretRef": { + "env": "OPENAI_API_KEY", + "secretName": "hwlab-code-agent-provider", + "secretKey": "openai-api-key", + "present": true, + "redacted": true + }, + "egress": { + "env": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", + "target": "dev-egress-proxy", + "present": true, + "matchesDevProxy": true, + "directPublicOpenAi": false, + "valueRedacted": true + }, + "secretMaterialRead": false, + "providerConnected": false, + "deployment": "hwlab-cloud-api", + "container": "hwlab-cloud-api", + "validationScope": "deployment-env-and-secretKeyRef-metadata-only" + }, + "validationGate": "no-secret provider env/secretKeyRef preservation", + "providerCallAttempted": false, + "secretValuesRead": false, + "kubernetesSecretDataRead": false, + "valuesRedacted": true + }, "applyStep": { "status": "pass", - "command": "kubectl apply --dry-run=server -k deploy/k8s/dev", - "stdout": "namespace/hwlab-dev unchanged (server dry run)\nconfigmap/hwlab-dev-health-contract configured (server dry run)\nservice/hwlab-agent-mgr unchanged (server dry run)\nservice/hwlab-agent-skills unchanged (server dry run)\nservice/hwlab-box-simu unchanged (server dry run)\nservice/hwlab-cli unchanged (server dry run)\nservice/hwlab-cloud-api unchanged (server dry run)\nservice/hwlab-cloud-web unchanged (server dry run)\nservice/hwlab-edge-proxy unchanged (server dry run)\nservice/hwlab-gateway unchanged (server dry run)\nservice/hwlab-gateway-simu unchanged (server dry run)\nservice/hwlab-patch-panel unchanged (server dry run)\nservice/hwlab-router unchanged (server dry run)\nservice/hwlab-tunnel-client unchanged (server dry run)\ndeployment.apps/hwlab-agent-mgr configured (server dry run)\ndeployment.apps/hwlab-agent-skills configured (server dry run)\ndeployment.apps/hwlab-box-simu configured (server dry run)\ndeployment.apps/hwlab-cloud-api configured (server dry run)\ndeployment.apps/hwlab-cloud-web configured (server dry run)\ndeployment.apps/hwlab-edge-proxy configured (server dry run)\ndeployment.apps/hwlab-gateway configured (server dry run)\ndeployment.apps/hwlab-gateway-simu configured (server dry run)\ndeployment.apps/hwlab-patch-panel configured (server dry run)\ndeployment.apps/hwlab-router configured (server dry run)\ndeployment.apps/hwlab-tunnel-client configured (server dry run)", - "stderr": "# Warning: 'commonLabels' is deprecated. Please use 'labels' instead. Run 'kustomize edit fix' to update your Kustomization automatically.\n# Warning: 'commonLabels' is deprecated. Please use 'labels' instead. Run 'kustomize edit fix' to update your Kustomization automatically.\nError from server (Invalid): error when applying patch:\n{\"metadata\":{\"annotations\":{\"kubectl.kubernetes.io/last-applied-configuration\":\"{\\\"apiVersion\\\":\\\"batch/v1\\\",\\\"kind\\\":\\\"Job\\\",\\\"metadata\\\":{\\\"annotations\\\":{},\\\"labels\\\":{\\\"app.kubernetes.io/name\\\":\\\"hwlab-agent-worker\\\",\\\"app.kubernetes.io/part-of\\\":\\\"hwlab\\\",\\\"hwlab.pikastech.local/environment\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/profile\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/service-id\\\":\\\"hwlab-agent-worker\\\"},\\\"name\\\":\\\"hwlab-agent-worker-template\\\",\\\"namespace\\\":\\\"hwlab-dev\\\"},\\\"spec\\\":{\\\"suspend\\\":true,\\\"template\\\":{\\\"metadata\\\":{\\\"labels\\\":{\\\"app.kubernetes.io/name\\\":\\\"hwlab-agent-worker\\\",\\\"app.kubernetes.io/part-of\\\":\\\"hwlab\\\",\\\"hwlab.pikastech.local/environment\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/profile\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/service-id\\\":\\\"hwlab-agent-worker\\\"}},\\\"spec\\\":{\\\"containers\\\":[{\\\"env\\\":[{\\\"name\\\":\\\"HWLAB_AGENT_SESSION_MODE\\\",\\\"value\\\":\\\"session-scoped\\\"}],\\\"image\\\":\\\"127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522\\\",\\\"name\\\":\\\"hwlab-agent-worker\\\"}],\\\"restartPolicy\\\":\\\"Never\\\"}}}}\\n\"}},\"spec\":{\"template\":{\"spec\":{\"$setElementOrder/containers\":[{\"name\":\"hwlab-agent-worker\"}],\"containers\":[{\"image\":\"127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522\",\"name\":\"hwlab-agent-worker\"}]}}}}\nto:\nResource: \"batch/v1, Resource=jobs\", GroupVersionKind: \"batch/v1, Kind=Job\"\nName: \"hwlab-agent-worker-template\", Namespace: \"hwlab-dev\"\nfor: \"deploy/k8s/dev\": error when patching \"deploy/k8s/dev\": Job.batch \"hwlab-agent-worker-template\" is invalid: spec.template: Invalid value: {\"labels\":{\"app.kubernetes.io/name\":\"hwlab-agent-worker\",\"app.kubernetes.io/part-of\":\"hwlab\",\"batch.kubernetes.io/controller-uid\":\"0f7b16d1-562b-40a3-8ff8-961c10d1b182\",\"batch.kubernetes.io/job-name\":\"hwlab-agent-worker-template\",\"controller-uid\":\"0f7b16d1-562b-40a3-8ff8-961c10d1b182\",\"hwlab.pikastech.local/environment\":\"dev\",\"hwlab.pikastech.local/profile\":\"dev\",\"hwlab.pikastech.local/service-id\":\"hwlab-agent-worker\",\"job-name\":\"hwlab-agent-worker-template\"},\"Spec\":{\"Volumes\":null,\"InitContainers\":null,\"Containers\":[{\"Name\":\"hwlab-agent-worker\",\"Image\":\"127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522\",\"Command\":null,\"Args\":null,\"WorkingDir\":\"\",\"Ports\":null,\"EnvFrom\":null,\"Env\":[{\"Name\":\"HWLAB_AGENT_SESSION_MODE\",\"Value\":\"session-scoped\",\"ValueFrom\":null}],\"Resources\":{\"Limits\":null,\"Requests\":null,\"Claims\":null},\"ResizePolicy\":null,\"RestartPolicy\":null,\"RestartPolicyRules\":null,\"VolumeMounts\":null,\"VolumeDevices\":null,\"LivenessProbe\":null,\"ReadinessProbe\":null,\"StartupProbe\":null,\"Lifecycle\":null,\"TerminationMessagePath\":\"/dev/termination-log\",\"TerminationMessagePolicy\":\"File\",\"ImagePullPolicy\":\"IfNotPresent\",\"SecurityContext\":null,\"Stdin\":false,\"StdinOnce\":false,\"TTY\":false}],\"EphemeralContainers\":null,\"RestartPolicy\":\"Never\",\"TerminationGracePeriodSeconds\":30,\"ActiveDeadlineSeconds\":null,\"DNSPolicy\":\"ClusterFirst\",\"NodeSelector\":null,\"ServiceAccountName\":\"\",\"AutomountServiceAccountToken\":null,\"NodeName\":\"\",\"SecurityContext\":{\"HostNetwork\":false,\"HostPID\":false,\"HostIPC\":false,\"ShareProcessNamespace\":null,\"HostUsers\":null,\"SELinuxOptions\":null,\"WindowsOptions\":null,\"RunAsUser\":null,\"RunAsGroup\":null,\"RunAsNonRoot\":null,\"SupplementalGroups\":null,\"SupplementalGroupsPolicy\":null,\"FSGroup\":null,\"FSGroupChangePolicy\":null,\"Sysctls\":null,\"SeccompProfile\":null,\"AppArmorProfile\":null,\"SELinuxChangePolicy\":null},\"ImagePullSecrets\":null,\"Hostname\":\"\",\"Subdomain\":\"\",\"SetHostnameAsFQDN\":null,\"Affinity\":null,\"SchedulerName\":\"default-scheduler\",\"Tolerations\":null,\"HostAliases\":null,\"PriorityClassName\":\"\",\"Priority\":null,\"PreemptionPolicy\":null,\"DNSConfig\":null,\"ReadinessGates\":null,\"RuntimeClassName\":null,\"Overhead\":null,\"EnableServiceLinks\":null,\"TopologySpreadConstraints\":null,\"OS\":null,\"SchedulingGates\":null,\"ResourceClaims\":null,\"Resources\":null,\"HostnameOverride\":null}}: field is immutable\nError from server (Invalid): error when applying patch:\n{\"metadata\":{\"annotations\":{\"kubectl.kubernetes.io/last-applied-configuration\":\"{\\\"apiVersion\\\":\\\"batch/v1\\\",\\\"kind\\\":\\\"Job\\\",\\\"metadata\\\":{\\\"annotations\\\":{},\\\"labels\\\":{\\\"app.kubernetes.io/name\\\":\\\"hwlab-cli\\\",\\\"app.kubernetes.io/part-of\\\":\\\"hwlab\\\",\\\"hwlab.pikastech.local/activation\\\":\\\"manual\\\",\\\"hwlab.pikastech.local/environment\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/profile\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/service-id\\\":\\\"hwlab-cli\\\"},\\\"name\\\":\\\"hwlab-cli-template\\\",\\\"namespace\\\":\\\"hwlab-dev\\\"},\\\"spec\\\":{\\\"suspend\\\":true,\\\"template\\\":{\\\"metadata\\\":{\\\"labels\\\":{\\\"app.kubernetes.io/name\\\":\\\"hwlab-cli\\\",\\\"app.kubernetes.io/part-of\\\":\\\"hwlab\\\",\\\"hwlab.pikastech.local/environment\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/profile\\\":\\\"dev\\\",\\\"hwlab.pikastech.local/service-id\\\":\\\"hwlab-cli\\\"}},\\\"spec\\\":{\\\"containers\\\":[{\\\"env\\\":[{\\\"name\\\":\\\"HWLAB_CLI_ENDPOINT\\\",\\\"value\\\":\\\"http://74.48.78.17:16667\\\"}],\\\"image\\\":\\\"127.0.0.1:5000/hwlab/hwlab-cli:7e29522\\\",\\\"name\\\":\\\"hwlab-cli\\\"}],\\\"restartPolicy\\\":\\\"Never\\\"}}}}\\n\"}},\"spec\":{\"template\":{\"spec\":{\"$setElementOrder/containers\":[{\"name\":\"hwlab-cli\"}],\"containers\":[{\"image\":\"127.0.0.1:5000/hwlab/hwlab-cli:7e29522\",\"name\":\"hwlab-cli\"}]}}}}\nto:\nResource: \"batch/v1, Resource=jobs\", GroupVersionKind: \"batch/v1, Kind=Job\"\nName: \"hwlab-cli-template\", Namespace: \"hwlab-dev\"\nfor: \"deploy/k8s/dev\": error when patching \"deploy/k8s/dev\": Job.batch \"hwlab-cli-template\" is invalid: spec.template: Invalid value: {\"labels\":{\"app.kubernetes.io/name\":\"hwlab-cli\",\"app.kubernetes.io/part-of\":\"hwlab\",\"batch.kubernetes.io/controller-uid\":\"ce763736-b67b-400c-8a09-5b3f62019b09\",\"batch.kubernetes.io/job-name\":\"hwlab-cli-template\",\"controller-uid\":\"ce763736-b67b-400c-8a09-5b3f62019b09\",\"hwlab.pikastech.local/environment\":\"dev\",\"hwlab.pikastech.local/profile\":\"dev\",\"hwlab.pikastech.local/service-id\":\"hwlab-cli\",\"job-name\":\"hwlab-cli-template\"},\"Spec\":{\"Volumes\":null,\"InitContainers\":null,\"Containers\":[{\"Name\":\"hwlab-cli\",\"Image\":\"127.0.0.1:5000/hwlab/hwlab-cli:7e29522\",\"Command\":null,\"Args\":null,\"WorkingDir\":\"\",\"Ports\":null,\"EnvFrom\":null,\"Env\":[{\"Name\":\"HWLAB_CLI_ENDPOINT\",\"Value\":\"http://74.48.78.17:16667\",\"ValueFrom\":null}],\"Resources\":{\"Limits\":null,\"Requests\":null,\"Claims\":null},\"ResizePolicy\":null,\"RestartPolicy\":null,\"RestartPolicyRules\":null,\"VolumeMounts\":null,\"VolumeDevices\":null,\"LivenessProbe\":null,\"ReadinessProbe\":null,\"StartupProbe\":null,\"Lifecycle\":null,\"TerminationMessagePath\":\"/dev/termination-log\",\"TerminationMessagePolicy\":\"File\",\"ImagePullPolicy\":\"IfNotPresent\",\"SecurityContext\":null,\"Stdin\":false,\"StdinOnce\":false,\"TTY\":false}],\"EphemeralContainers\":null,\"RestartPolicy\":\"Never\",\"TerminationGracePeriodSeconds\":30,\"ActiveDeadlineSeconds\":null,\"DNSPolicy\":\"ClusterFirst\",\"NodeSelector\":null,\"ServiceAccountName\":\"\",\"AutomountServiceAccountToken\":null,\"NodeName\":\"\",\"SecurityContext\":{\"HostNetwork\":false,\"HostPID\":false,\"HostIPC\":false,\"ShareProcessNamespace\":null,\"HostUsers\":null,\"SELinuxOptions\":null,\"WindowsOptions\":null,\"RunAsUser\":null,\"RunAsGroup\":null,\"RunAsNonRoot\":null,\"SupplementalGroups\":null,\"SupplementalGroupsPolicy\":null,\"FSGroup\":null,\"FSGroupChangePolicy\":null,\"Sysctls\":null,\"SeccompProfile\":null,\"AppArmorProfile\":null,\"SELinuxChangePolicy\":null},\"ImagePullSecrets\":null,\"Hostname\":\"\",\"Subdomain\":\"\",\"SetHostnameAsFQDN\":null,\"Affinity\":null,\"SchedulerName\":\"default-scheduler\",\"Tolerations\":null,\"HostAliases\":null,\"PriorityClassName\":\"\",\"Priority\":null,\"PreemptionPolicy\":null,\"DNSConfig\":null,\"ReadinessGates\":null,\"RuntimeClassName\":null,\"Overhead\":null,\"EnableServiceLinks\":null,\"TopologySpreadConstraints\":null,\"OS\":null,\"SchedulingGates\":null,\"ResourceClaims\":null,\"Resources\":null,\"HostnameOverride\":null}}: field is immutable", - "mutationAttempted": false, - "expectedImmutableTemplateJobDryRun": true, - "expectedImmutableTemplateJobs": [ - "hwlab-agent-worker-template", - "hwlab-cli-template" - ] + "command": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl apply -k deploy/k8s/dev", + "stdout": "namespace/hwlab-dev unchanged\nconfigmap/hwlab-dev-health-contract unchanged\nservice/hwlab-agent-mgr unchanged\nservice/hwlab-agent-skills unchanged\nservice/hwlab-box-simu unchanged\nservice/hwlab-box-simu-1 unchanged\nservice/hwlab-box-simu-2 unchanged\nservice/hwlab-cli unchanged\nservice/hwlab-cloud-api unchanged\nservice/hwlab-cloud-web unchanged\nservice/hwlab-edge-proxy unchanged\nservice/hwlab-gateway unchanged\nservice/hwlab-gateway-simu unchanged\nservice/hwlab-gateway-simu-1 unchanged\nservice/hwlab-gateway-simu-2 unchanged\nservice/hwlab-patch-panel unchanged\nservice/hwlab-router unchanged\nservice/hwlab-tunnel-client unchanged\ndeployment.apps/hwlab-agent-mgr configured\ndeployment.apps/hwlab-agent-skills configured\ndeployment.apps/hwlab-cloud-api configured\ndeployment.apps/hwlab-cloud-web configured\ndeployment.apps/hwlab-edge-proxy configured\ndeployment.apps/hwlab-gateway configured\ndeployment.apps/hwlab-patch-panel configured\ndeployment.apps/hwlab-router configured\ndeployment.apps/hwlab-tunnel-client configured\nstatefulset.apps/hwlab-box-simu configured\nstatefulset.apps/hwlab-gateway-simu configured\njob.batch/hwlab-agent-worker-template created\njob.batch/hwlab-cli-template created", + "stderr": "# Warning: 'commonLabels' is deprecated. Please use 'labels' instead. Run 'kustomize edit fix' to update your Kustomization automatically.\n# Warning: 'commonLabels' is deprecated. Please use 'labels' instead. Run 'kustomize edit fix' to update your Kustomization automatically.", + "mutationAttempted": true, + "expectedImmutableTemplateJobDryRun": false, + "expectedImmutableTemplateJobs": [] }, "manualCommands": { "status": "ready", @@ -609,28 +1148,26 @@ "namespace": "hwlab-dev", "strategy": "DEV-only Kubernetes rollback using deployment revision history; do not touch PROD or UniDesk services.", "captureBeforeApply": [ - "kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide", - "kubectl -n hwlab-dev get deployments -o json" + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev get deployments -o json" ], "deploymentRollbackCommands": [ - "kubectl -n hwlab-dev rollout undo deployment/hwlab-cloud-api", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-cloud-web", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-agent-mgr", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-gateway", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-gateway-simu", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-box-simu", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-patch-panel", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-router", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-tunnel-client", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-edge-proxy", - "kubectl -n hwlab-dev rollout undo deployment/hwlab-agent-skills" + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-cloud-api", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-cloud-web", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-agent-mgr", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-gateway", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-patch-panel", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-router", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-tunnel-client", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-edge-proxy", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout undo deployment/hwlab-agent-skills" ], "jobCleanupCommands": [ - "kubectl -n hwlab-dev delete job hwlab-agent-worker-template --ignore-not-found", - "kubectl -n hwlab-dev delete job hwlab-cli-template --ignore-not-found" + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev delete job hwlab-agent-worker-template --ignore-not-found", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev delete job hwlab-cli-template --ignore-not-found" ], "postRollbackChecks": [ - "kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s", + "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s", "curl -fsS http://74.48.78.17:16667/health/live" ] }, @@ -638,106 +1175,106 @@ "artifactEvidence": [ { "serviceId": "hwlab-cloud-api", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522", - "digest": "sha256:090b6f919be3b3c7594ca4073b193351689fc525f478b64d54d393725cb01ecf", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:4d2a0ba", + "digest": "sha256:a295d92e3e11c4a7f04d80b90c5a0f4e59ffd6a83c3aa3ed8f86e14db1a88b14", "publishState": "published" }, { "serviceId": "hwlab-cloud-web", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522", - "digest": "sha256:bc6e836b2bb796b30562ed3ae93d8534146a3b74a310906481c8641646d1b8f0", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "digest": "sha256:fe834adbfd8cf0ca7e514af6c71dfe7243540084f81e6ef124549e4071e1d200", "publishState": "published" }, { "serviceId": "hwlab-agent-mgr", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522", - "digest": "sha256:4d9b8944bc52012bb0a4a3a345f8939381d2804032e544d0c98f49fac0e46bfd", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:4d2a0ba", + "digest": "sha256:7df780347df148f1303c498f35aba19d9c10f2f86899e9729afda978e7a27b2d", "publishState": "published" }, { "serviceId": "hwlab-agent-worker", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522", - "digest": "sha256:2582176e2c35a2bfbce4edfd922749397d45f6f1f8c04d0394c501f7d87323ee", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:4d2a0ba", + "digest": "sha256:21ff26ae035b97a181b3986f46d27305879502fb4aab30a94924ce9f8ab6bcd6", "publishState": "published" }, { "serviceId": "hwlab-gateway", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522", - "digest": "sha256:290341fd6e6f036aa953943072ad58e0d0cfcc0e13950e6090f941b3310755a6", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:4d2a0ba", + "digest": "sha256:cdb8c86de03d9c1ab2c224b5d50d4048a8336942e507793f9fd450020f91b028", "publishState": "published" }, { "serviceId": "hwlab-gateway-simu", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522", - "digest": "sha256:02627f880c7ddf2102e2255a43097d1f604cfb7a22215736e328287e27fc5f03", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:4d2a0ba", + "digest": "sha256:daae618cdc7fb176d7dc769956e3d5948d1abade09e35dd29daf9c86a37f8fd1", "publishState": "published" }, { "serviceId": "hwlab-box-simu", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522", - "digest": "sha256:e02c270c71f83a2f4624722bb84616bea8374ef43439f27bdce310e4baa8dcca", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:4d2a0ba", + "digest": "sha256:5ba916ed43a8e35e5052f16fd380a3e6004aea10b2e99b8375cc5ba6439702e7", "publishState": "published" }, { "serviceId": "hwlab-patch-panel", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522", - "digest": "sha256:f730323f83c62797f382932582a05df921045c86383dad7951f3d639b32456a9", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:4d2a0ba", + "digest": "sha256:7df24c38a83ccf743a155f8b913541a4d9224e0436c646206a9e5e4a9d43192f", "publishState": "published" }, { "serviceId": "hwlab-router", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522", - "digest": "sha256:7c40c573078a9c325402bd47b2eb78a46c0c80564a228cf7c9fcf0cfba5bc6d2", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-router:4d2a0ba", + "digest": "sha256:c918d15ebee2175e720ecb0c854701bed51a1e7753a996f9e69b99e8d530c77b", "publishState": "published" }, { "serviceId": "hwlab-tunnel-client", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522", - "digest": "sha256:b141dc88557b8f6391aa082fdb37c6f0db5bc3a76c97bb39ba00354a9208abd8", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:4d2a0ba", + "digest": "sha256:ba8e496adf33ba7478c563291b84df0d30958f70576b23a8083972a5e5884e23", "publishState": "published" }, { "serviceId": "hwlab-edge-proxy", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522", - "digest": "sha256:792505139793c3f3da7769f9dc9d1cb3203ff92797305b62e8f12207c69a9831", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:4d2a0ba", + "digest": "sha256:e07c05b6b06aef750d02d9f82a34f3b43a1a9df3a668fe60d348abe5931a6650", "publishState": "published" }, { "serviceId": "hwlab-cli", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522", - "digest": "sha256:43d1c175091bfb462eddd7dd22a780d6485d86bdb052c36b78201606f48fadc1", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:4d2a0ba", + "digest": "sha256:3d5baeb3343b81f62f20c3ffd143e55a899a697ebb5f1ac294bf89ace550a367", "publishState": "published" }, { "serviceId": "hwlab-agent-skills", - "deployCommitId": "7e29522", - "catalogCommitId": "7e29522", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522", - "digest": "sha256:c75510038dc33196dc254472e78101d545c0d777a7b33b531f02b39faf2588b5", + "deployCommitId": "4d2a0ba", + "catalogCommitId": "4d2a0ba", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:4d2a0ba", + "digest": "sha256:fd88f8f2d2d43d4420c5f70306f5810cb756bea9c0b206410cb7fa694cb67ce1", "publishState": "published" } ], @@ -769,6 +1306,23 @@ "missingManifest": [], "missingK8s": [], "missingSecretRefs": [], + "mismatchedNonSecretEnv": [], + "missingDnsContract": [], + "dns": { + "ready": true, + "source": "optional-public-dns-alias", + "serviceName": "cloud-api-db", + "namespace": "hwlab-dev", + "port": 5432, + "portName": "postgres", + "requiredForReadiness": false, + "usedForProbe": false, + "servicePresent": false, + "secretMaterialRead": false, + "liveDbEvidence": false, + "missing": [], + "actual": {} + }, "runtimeHealth": { "status": "degraded", "ready": false, @@ -778,6 +1332,7 @@ "connectionChecked": false, "connectionAttempted": false, "connectionResult": "unknown", + "liveDbEvidence": false, "requiredEnv": [ { "name": "HWLAB_CLOUD_DB_URL", @@ -803,147 +1358,235 @@ "redacted": true } ], + "endpointSource": "secret-url-host", + "optionalPublicDnsAlias": { + "source": "optional-public-dns-alias", + "serviceName": "cloud-api-db", + "namespace": "hwlab-dev", + "port": 5432, + "portName": "postgres", + "requiredForReadiness": false, + "usedForProbe": false, + "envPresent": false, + "readyGate": "not_readiness_authority" + }, "connection": { "attempted": false, "result": "unknown", "endpointRedacted": true, "valueRedacted": true }, + "redaction": { + "valuesRedacted": true, + "endpointRedacted": true, + "valueRedacted": true, + "secretMaterialRead": false, + "secretRefsOnly": true + }, + "readinessLayers": { + "dns": { + "status": "not_proven", + "result": "unknown" + }, + "tcp": { + "status": "not_proven", + "result": "unknown" + }, + "ssl": { + "status": "not_proven", + "result": "unknown" + }, + "auth": { + "status": "not_proven", + "result": "unknown" + }, + "schema": { + "status": "not_proven", + "result": "unknown" + }, + "migration": { + "status": "not_proven", + "result": "unknown" + }, + "durability": { + "status": "not_proven", + "result": "unknown" + } + }, "blocker": null, - "liveDbEvidence": false, "fixtureEvidence": false }, "secretMaterialRead": false, "valuesRedacted": true, "liveDbEvidence": false, "fixtureEvidence": false, - "note": "This check records DB env/Secret reference presence only. It is not live DB evidence." + "note": "This check records DB env, Secret reference, and Secret URL host authority only. Optional cloud-api-db alias presence is diagnostic and is not live DB evidence." }, "k8sManifest": { "resourceCounts": { "workloads": 13, - "services": 12, + "services": 16, "configMaps": 1 }, "images": [ { "serviceId": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:4d2a0ba", "kind": "Deployment", "name": "hwlab-cloud-api" }, { "serviceId": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", "kind": "Deployment", "name": "hwlab-cloud-web" }, { "serviceId": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:4d2a0ba", "kind": "Deployment", "name": "hwlab-agent-mgr" }, { "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:4d2a0ba", "kind": "Job", "name": "hwlab-agent-worker-template" }, { "serviceId": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:4d2a0ba", "kind": "Deployment", "name": "hwlab-gateway" }, { "serviceId": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522", - "kind": "Deployment", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:4d2a0ba", + "kind": "StatefulSet", "name": "hwlab-gateway-simu" }, { "serviceId": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522", - "kind": "Deployment", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:4d2a0ba", + "kind": "StatefulSet", "name": "hwlab-box-simu" }, { "serviceId": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:4d2a0ba", "kind": "Deployment", "name": "hwlab-patch-panel" }, { "serviceId": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-router:4d2a0ba", "kind": "Deployment", "name": "hwlab-router" }, { "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:4d2a0ba", "kind": "Deployment", "name": "hwlab-tunnel-client" }, { "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:4d2a0ba", "kind": "Deployment", "name": "hwlab-edge-proxy" }, { "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:4d2a0ba", "kind": "Job", "name": "hwlab-cli-template" }, { "serviceId": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:4d2a0ba", "kind": "Deployment", "name": "hwlab-agent-skills" } + ], + "runtimeIdentityEnv": [ + { + "serviceId": "hwlab-cloud-web", + "kind": "Deployment", + "name": "hwlab-cloud-web", + "container": "hwlab-cloud-web", + "expected": { + "HWLAB_COMMIT_ID": "4d2a0ba", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "HWLAB_IMAGE_TAG": "4d2a0ba" + }, + "status": "match", + "matchesDesired": true, + "fields": [ + { + "name": "HWLAB_COMMIT_ID", + "expected": "4d2a0ba", + "actual": "4d2a0ba", + "status": "match" + }, + { + "name": "HWLAB_IMAGE", + "expected": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "actual": "127.0.0.1:5000/hwlab/hwlab-cloud-web:4d2a0ba", + "status": "match" + }, + { + "name": "HWLAB_IMAGE_TAG", + "expected": "4d2a0ba", + "actual": "4d2a0ba", + "status": "match" + } + ], + "drift": [] + } ] }, "clusterObservation": { "status": "pass", "executor": "/usr/local/bin/kubectl", + "kubeconfig": "/var/lib/unidesk/code-queue/kubeconfig", + "kubeconfigSource": "env:KUBECONFIG", + "commandPrefix": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl", + "readCommand": "KUBECONFIG=/var/lib/unidesk/code-queue/kubeconfig kubectl get pods,services,configmaps,deployments,jobs -n hwlab-dev -o json", "version": "clientVersion:\n buildDate: \"2026-05-12T09:54:35Z\"\n compiler: gc\n gitCommit: 756939600b9a7180fc2df6550a4585b638875e67\n gitTreeState: clean\n gitVersion: v1.36.1\n goVersion: go1.26.2\n major: \"1\"\n minor: \"36\"\n platform: linux/amd64\nkustomizeVersion: v5.8.1", "resources": [ { "kind": "Pod", - "name": "hwlab-agent-mgr-75544c7747-5dzt9", + "name": "hwlab-agent-mgr-6487c6d6c7-5xhdx", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-agent-skills-849d9976f4-2nsmw", + "name": "hwlab-agent-skills-56488f6b58-h8jkx", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-box-simu-55688cbcc7-pftxc", + "name": "hwlab-box-simu-0", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-box-simu-55688cbcc7-sq5fx", + "name": "hwlab-box-simu-1", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-cloud-api-f48dcdfb-9n6zh", + "name": "hwlab-cloud-api-794868f84d-q8mr7", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-cloud-web-cd9655b98-gxtmc", + "name": "hwlab-cloud-web-5c746559b6-p9ktp", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-edge-proxy-567f978cdd-8fwc8", + "name": "hwlab-edge-proxy-5fbb997574-njgwz", "phase": "Running" }, { @@ -953,27 +1596,27 @@ }, { "kind": "Pod", - "name": "hwlab-gateway-simu-699fd486b-j9pk7", + "name": "hwlab-gateway-simu-0", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-gateway-simu-699fd486b-phcgp", + "name": "hwlab-gateway-simu-1", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-patch-panel-757f9f44d5-7np8r", + "name": "hwlab-patch-panel-5d75c58559-w7dq7", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-router-5d9cbdf89b-p7r94", + "name": "hwlab-router-5777cb4d7d-kn8tz", "phase": "Running" }, { "kind": "Pod", - "name": "hwlab-tunnel-client-65c7858c8-wjqsh", + "name": "hwlab-tunnel-client-65f85d7c8d-rpqrb", "phase": "Running" }, { @@ -988,6 +1631,14 @@ "kind": "Service", "name": "hwlab-box-simu" }, + { + "kind": "Service", + "name": "hwlab-box-simu-1" + }, + { + "kind": "Service", + "name": "hwlab-box-simu-2" + }, { "kind": "Service", "name": "hwlab-cli" @@ -1012,6 +1663,14 @@ "kind": "Service", "name": "hwlab-gateway-simu" }, + { + "kind": "Service", + "name": "hwlab-gateway-simu-1" + }, + { + "kind": "Service", + "name": "hwlab-gateway-simu-2" + }, { "kind": "Service", "name": "hwlab-patch-panel" @@ -1048,12 +1707,6 @@ "readyReplicas": 1, "replicas": 1 }, - { - "kind": "Deployment", - "name": "hwlab-box-simu", - "readyReplicas": 2, - "replicas": 2 - }, { "kind": "Deployment", "name": "hwlab-cloud-api", @@ -1082,12 +1735,6 @@ "kind": "Deployment", "name": "hwlab-gateway" }, - { - "kind": "Deployment", - "name": "hwlab-gateway-simu", - "readyReplicas": 2, - "replicas": 2 - }, { "kind": "Deployment", "name": "hwlab-patch-panel", @@ -1120,17 +1767,9 @@ "status": "pass", "url": "http://74.48.78.17:16667/health/live", "httpStatus": 200, - "body": "{\"serviceId\":\"hwlab-cloud-api\",\"environment\":\"dev\",\"status\":\"degraded\",\"service\":{\"id\":\"hwlab-cloud-api\",\"role\":\"cloud-api\",\"healthPath\":\"/health\",\"livePath\":\"/health/live\"},\"commit\":{\"id\":\"cb35ada\",\"source\":\"runtime-env\"},\"image\":{\"reference\":\"127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada\",\"tag\":\"cb35ada\",\"digest\":\"unknown\"},\"endpoint\":\"http://74.48.78.17:16667\",\"observedAt\":\"2026-05-22T05:33:08.461Z\",\"db\":{\"contractVersion\":\"v1\",\"environment\":\"dev\",\"connected\":false,\"connectionChecked\":false,\"c" + "body": "{\"serviceId\":\"hwlab-cloud-api\",\"environment\":\"dev\",\"status\":\"degraded\",\"ready\":false,\"readiness\":{\"contractVersion\":\"v3\",\"ready\":false,\"status\":\"degraded\",\"blockerCodes\":[\"runtime_durable_adapter_auth_blocked\"],\"blockers\":[{\"code\":\"runtime_durable_adapter_auth_blocked\",\"type\":\"runtime_blocker\",\"scope\":\"runtime-durable-adapter\",\"status\":\"open\",\"sourceIssue\":\"pikasTech/HWLAB#164\",\"summary\":\"Postgres runtime adapter reached the DB driver but authentication or authorization blocked the schema query\"" } }, "blockers": [], - "notes": "DEV-only result. No PROD resources, secret values, force push, heavy e2e, browser e2e, or UniDesk runtime substitute were used.", - "reportLifecycle": { - "version": "v1", - "state": "active", - "activeEndpoint": "http://74.48.78.17:16667", - "activeBrowserEndpoint": "http://74.48.78.17:16666", - "deprecatedEndpoint": null, - "summary": "Current DEV deploy apply report; active public API/health endpoint is 16667 and browser endpoint is 16666." - } + "notes": "DEV-only result. No PROD resources, secret values, force push, heavy e2e, browser e2e, or UniDesk runtime substitute were used." } From 973f9511352dcfeabde4cf8fe553c16dac17134f Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 08:38:18 +0000 Subject: [PATCH 2/2] test: add workbench layout smoke --- AGENTS.md | 1 + docs/reference/cloud-workbench.md | 30 + scripts/dev-cloud-workbench-layout-smoke.mjs | 89 ++ scripts/dev-cloud-workbench-smoke.test.mjs | 51 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 858 +++++++++++++++++- 5 files changed, 979 insertions(+), 50 deletions(-) create mode 100644 scripts/dev-cloud-workbench-layout-smoke.mjs diff --git a/AGENTS.md b/AGENTS.md index 48bab2d1..1bd6cbee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - Cloud Web 静态检查:`npm run web:check` - Cloud Web 构建:`npm run web:build` - Cloud Web M3 只读护栏:`npm run web:m3-readonly` +- Cloud Workbench 布局/遮挡 smoke:`node scripts/dev-cloud-workbench-layout-smoke.mjs --static` - DEV artifact 发布预检:`npm run dev-artifact:preflight` - runner GitHub 可见性预检:`npm run runner:issue-visibility:preflight` - D601 k3s 只读观测:`npm run d601:k3s:readonly` diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 273eccce..e22bf389 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -97,10 +97,40 @@ when identity drift blocks the full journey. It never sends `POST must not be used as a DEV-LIVE reply, Secret, mutation, or M3/M4/M5 acceptance claim. +## Layout Smoke + +`scripts/dev-cloud-workbench-layout-smoke.mjs` 是 #273 前端布局/遮挡护栏的 +runner 入口。它使用 Playwright 覆盖 `1366x768`、`1024x768` 和 `390x844` +三档视口,检查 M3 控制区、Code Agent 输入区、右侧硬件/可信记录容器、当前 +`/gate` 内部页、外层滚动锁和内部面板滚动。命中测试使用真实 Playwright +点击与 `document.elementsFromPoint()`;不得使用 `force: true` 或纯 DOM +dispatch 绕过用户命中。 + +常用命令: + +```sh +node scripts/dev-cloud-workbench-layout-smoke.mjs --static --report reports/dev-gate/dev-cloud-workbench-layout.json +node scripts/dev-cloud-workbench-layout-smoke.mjs --build --report reports/dev-gate/dev-cloud-workbench-layout-build.json +node scripts/dev-cloud-workbench-layout-smoke.mjs --live --url http://74.48.78.17:16666/ --report reports/dev-gate/dev-cloud-workbench-layout-live.json +``` + +报告字段必须能定位 `status=pass|blocked|skip`、`viewport`、`selector`、 +`failureType` 和 artifact 路径。当前允许的 `failureType` 包括 `overlap`、 +`covered-hit-target`、`overflow`、`outer-scroll-regression`、`screenshot-diff` +以及未上线覆盖项的 `skip`。脚本会保存右侧面板、M3 控制表单、Code Agent +输入区和 `/gate` 当前页局部截图,但不做整页像素 diff 门禁。 + +#287 的硬件状态标签化和 #288 的 `/gate` 单一大表如果尚未上线,layout smoke +只能把对应覆盖项记录为 `skip`,同时继续检查当前可见容器的溢出、遮挡和外层 +滚动。该 UI smoke 只能证明布局/可点击性护栏通过,不等于 M3 DEV-LIVE 硬件 +闭环通过,也不能替代 #227 的 DO/DI 功能验收。 + ## Stable Sources - [pikasTech/HWLAB#99](https://github.com/pikasTech/HWLAB/issues/99): default Cloud Workbench direction. +- [pikasTech/HWLAB#273](https://github.com/pikasTech/HWLAB/issues/273): + Playwright layout/overlap smoke guardrail. - [pikasTech/HWLAB#108](https://github.com/pikasTech/HWLAB/issues/108): no outer scroll, Chinese UI, internal Markdown help. - [code-agent-chat-readiness.md](code-agent-chat-readiness.md): Code Agent diff --git a/scripts/dev-cloud-workbench-layout-smoke.mjs b/scripts/dev-cloud-workbench-layout-smoke.mjs new file mode 100644 index 00000000..71b7fc82 --- /dev/null +++ b/scripts/dev-cloud-workbench-layout-smoke.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + parseSmokeArgs, + printSmokeHelp, + runDevCloudWorkbenchLayoutSmoke +} from "./src/dev-cloud-workbench-smoke-lib.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function parseLayoutSmokeArgs(argv) { + if (argv.includes("--help")) return { help: true }; + const normalized = ["--layout"]; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--static" || arg === "--source") { + continue; + } + if (arg === "--live") { + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { + normalized.push("--url"); + normalized.push(next); + index += 1; + } else { + normalized.push("--url"); + normalized.push("http://74.48.78.17:16666/"); + } + continue; + } + normalized.push(arg); + if (["--url", "--report"].includes(arg)) { + index += 1; + if (!argv[index]) throw new Error(`${arg} requires a value`); + normalized.push(argv[index]); + } + } + return parseSmokeArgs(normalized); +} + +export function layoutSmokeExitCode(report) { + return report.status === "pass" || report.status === "usage" ? 0 : report.status === "skip" ? 2 : 2; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + const args = parseLayoutSmokeArgs(process.argv.slice(2)); + const report = args.help + ? { + ...printSmokeHelp(), + command: "node scripts/dev-cloud-workbench-layout-smoke.mjs [--static|--build|--live --url http://74.48.78.17:16666/] [--report reports/dev-gate/dev-cloud-workbench-layout.json]", + layoutNotes: [ + "默认 --static 使用 source/static 本地静态服务。", + "--build 会先运行 web/hwlab-cloud-web/scripts/build.mjs,再检查本地 dist 构建产物。", + "--live --url http://74.48.78.17:16666/ 只做 DEV live UI 布局/命中/溢出检查,不发送 Code Agent 消息,不调用 M3 IO,不等于 M3 DEV-LIVE 功能通过。", + "结构化失败会包含 status、viewport、selector、failureType、artifact screenshot/report path。" + ] + } + : await runDevCloudWorkbenchLayoutSmoke(args); + if (args.reportPath && report.status !== "usage") { + const reportPath = path.resolve(process.cwd(), args.reportPath); + report.artifacts = { ...(report.artifacts ?? {}), reportPath }; + await mkdir(path.dirname(reportPath), { recursive: true }); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + } + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = layoutSmokeExitCode(report); + } catch (error) { + const failure = { + status: "blocked", + mode: "layout-browser", + generatedAt: new Date().toISOString(), + failureType: "blocked", + selector: null, + viewport: null, + artifact: null, + reportPath: null, + summary: error instanceof Error ? error.message : String(error) + }; + process.stderr.write(`[dev-cloud-workbench-layout-smoke] ${error instanceof Error ? error.stack : String(error)}\n`); + process.stdout.write(`${JSON.stringify(failure, null, 2)}\n`); + process.exitCode = 2; + } +} + +export { repoRoot }; diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 91db6250..9c0c9d6d 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -19,6 +19,9 @@ import { decideSmokeReportWrite, smokeCliExitCode } from "./dev-cloud-workbench-smoke.mjs"; +import { + parseLayoutSmokeArgs +} from "./dev-cloud-workbench-layout-smoke.mjs"; const sourceIdentity = Object.freeze({ status: "observed", @@ -161,12 +164,37 @@ test("smoke args include reusable layout-only browser mode", () => { assert.equal(local.mode, "layout"); assert.equal(local.urlExplicit, undefined); + const build = parseSmokeArgs(["--layout", "--build"]); + assert.equal(build.mode, "layout"); + assert.equal(build.build, true); + const live = parseSmokeArgs(["--layout", "--url", "http://74.48.78.17:16666/"]); assert.equal(live.mode, "layout"); assert.equal(live.url, "http://74.48.78.17:16666/"); assert.equal(live.urlExplicit, true); }); +test("dedicated layout smoke CLI supports static build and DEV live shorthand", () => { + const local = parseLayoutSmokeArgs(["--static"]); + assert.equal(local.mode, "layout"); + assert.equal(local.urlExplicit, undefined); + + const build = parseLayoutSmokeArgs(["--build", "--report", "reports/dev-gate/dev-cloud-workbench-layout.json"]); + assert.equal(build.mode, "layout"); + assert.equal(build.build, true); + assert.equal(build.reportPath, "reports/dev-gate/dev-cloud-workbench-layout.json"); + + const liveDefault = parseLayoutSmokeArgs(["--live"]); + assert.equal(liveDefault.mode, "layout"); + assert.equal(liveDefault.url, "http://74.48.78.17:16666/"); + assert.equal(liveDefault.urlExplicit, true); + + const liveExplicit = parseLayoutSmokeArgs(["--live", "--url", "http://74.48.78.17:16666/"]); + assert.equal(liveExplicit.mode, "layout"); + assert.equal(liveExplicit.url, "http://74.48.78.17:16666/"); + assert.equal(liveExplicit.urlExplicit, true); +}); + test("source/default smoke covers #278 sidebar collapse static contract", () => { const report = runDevCloudWorkbenchStaticSmoke(); assert.equal(report.checks.find((check) => check.id === "feedback-278-sidebar-collapse-contract")?.status, "pass"); @@ -447,12 +475,28 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async "layout-desktop-expanded", "layout-desktop-collapsed", "layout-desktop-restored", + "layout-narrow-desktop-expanded", + "layout-narrow-desktop-collapsed", + "layout-narrow-desktop-restored", "layout-mobile-collapsed", - "layout-mobile-drawer" + "layout-mobile-drawer", + "layout-gate-desktop", + "layout-gate-narrow-desktop", + "layout-gate-mobile" ]) { assert.equal(report.checks.find((check) => check.id === id)?.status, "pass", id); } + assert.deepEqual( + report.viewports.map((viewport) => `${viewport.width}x${viewport.height}`), + ["1366x768", "1024x768", "390x844"] + ); + assert.equal(Array.isArray(report.failures), true); + assert.equal(Array.isArray(report.skipped), true); + assert.equal(report.safety.hitTestMethod.includes("elementsFromPoint"), true); + assert.equal(report.checks.find((check) => check.id === "layout-issue-287-future-hardware-status-tabs")?.status, "skip"); + assert.equal(report.checks.find((check) => check.id === "layout-issue-288-future-single-table-gate")?.status, "skip"); + const desktopCollapsed = report.checks.find((check) => check.id === "layout-desktop-collapsed")?.observations; assert.equal(desktopCollapsed.explorerCollapsed, true); assert.equal(desktopCollapsed.toggle.ariaLabel, "展开左侧资源树"); @@ -464,4 +508,9 @@ test("layout smoke verifies desktop and mobile sidebar collapse geometry", async assert.equal(mobileCollapsed.explorerCollapsed, true); assert.equal(mobileCollapsed.defaultCollapsedMobile, true); assert.equal(mobileCollapsed.keyTargetsReachable, true); + + const desktopExpanded = report.checks.find((check) => check.id === "layout-desktop-expanded")?.observations; + assert.equal(desktopExpanded.failures.length, 0); + assert.equal(desktopExpanded.semanticOverlapChecks.every((check) => !check.overlaps), true); + assert.equal(desktopExpanded.overflowChecks.every((check) => check.ok), true); }); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 94c3c793..2469d8b4 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -283,6 +283,9 @@ export function parseSmokeArgs(argv) { args.mode = "dom-only"; } else if (arg === "--layout") { args.mode = "layout"; + } else if (arg === "--build") { + args.mode = "layout"; + args.build = true; } else if (arg === "--mobile") { args.mode = "mobile"; args.mobile = true; @@ -819,7 +822,12 @@ async function runLiveDomOnlySmoke(args) { } export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { - const useLiveUrl = args.urlExplicit === true; + const useLiveUrl = args.live === true || args.urlExplicit === true; + const layoutSourceMode = useLiveUrl ? "dev-live" : args.build === true ? "local-build" : "source-static"; + const artifactRoot = path.resolve( + repoRoot, + args.artifactDir ?? path.join("tmp", "dev-cloud-workbench-layout-smoke", layoutArtifactTimestamp()) + ); let chromium; try { ({ chromium } = await importPlaywright()); @@ -854,87 +862,200 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { package: "playwright", declaredIn: "package.json", installCommand: playwrightDependencyCommand + }, + artifacts: { + screenshotDir: artifactRoot, + reportPath: args.reportPath ?? null } }; } - const server = useLiveUrl ? null : await startStaticWebServer(); + if (!useLiveUrl && args.build === true) { + execFileSync("node", ["web/hwlab-cloud-web/scripts/build.mjs"], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 30000 + }); + } + await fs.promises.mkdir(artifactRoot, { recursive: true }); + + const server = useLiveUrl + ? null + : await startStaticWebServer({ rootDir: args.build === true ? path.join(webRoot, "dist") : webRoot }); const url = useLiveUrl ? args.url : server.url; let browser; try { browser = await chromium.launch({ headless: true }); - const desktop = await inspectWorkbenchLayoutViewport(browser, url, { - width: 1366, - height: 768, - isMobile: false - }); - const narrow = await inspectWorkbenchLayoutViewport(browser, url, { - width: 390, - height: 844, - isMobile: true - }); + const layoutViewports = [ + { id: "desktop", width: 1366, height: 768, isMobile: false }, + { id: "narrow-desktop", width: 1024, height: 768, isMobile: false }, + { id: "mobile", width: 390, height: 844, isMobile: true } + ]; + const viewportResults = {}; + for (const viewport of layoutViewports) { + viewportResults[viewport.id] = await inspectWorkbenchLayoutViewport(browser, url, viewport, { artifactRoot }); + } + const desktop = viewportResults.desktop; + const narrowDesktop = viewportResults["narrow-desktop"]; + const mobile = viewportResults.mobile; const checks = [ { id: "layout-desktop-expanded", status: desktop.expanded.pass ? "pass" : "blocked", - summary: "Desktop expanded layout keeps navigation/resources visible, key controls reachable, and no outer scroll.", + viewport: desktop.expanded.viewport, + summary: "Desktop 1366x768 expanded layout keeps M3 controls, Code Agent input, hardware panels, hit targets, overflow, and outer scroll in contract.", observations: desktop.expanded }, { id: "layout-desktop-collapsed", status: desktop.collapsed.pass ? "pass" : "blocked", - summary: "Desktop collapsed layout hides the resource tree, increases Agent/M3 usable width, and keeps key controls reachable.", + viewport: desktop.collapsed.viewport, + summary: "Desktop 1366x768 collapsed layout hides the resource tree, increases Agent/M3 usable width, and keeps key controls reachable.", observations: desktop.collapsed }, { id: "layout-desktop-restored", status: desktop.restored.pass ? "pass" : "blocked", + viewport: desktop.restored.viewport, summary: "Desktop expand action restores the left navigation/resource tree without layout overlap.", observations: desktop.restored }, + { + id: "layout-narrow-desktop-expanded", + status: narrowDesktop.expanded.pass ? "pass" : "blocked", + viewport: narrowDesktop.expanded.viewport, + summary: "Narrow desktop 1024x768 layout keeps the right hardware area below the workbench without covered controls or outer scroll.", + observations: narrowDesktop.expanded + }, + { + id: "layout-narrow-desktop-collapsed", + status: narrowDesktop.collapsed.pass ? "pass" : "blocked", + viewport: narrowDesktop.collapsed.viewport, + summary: "Narrow desktop 1024x768 collapsed layout releases left-sidebar width without hiding M3 or Code Agent controls.", + observations: narrowDesktop.collapsed + }, + { + id: "layout-narrow-desktop-restored", + status: narrowDesktop.restored.pass ? "pass" : "blocked", + viewport: narrowDesktop.restored.viewport, + summary: "Narrow desktop 1024x768 restored layout returns the resource tree without incoherent overlap.", + observations: narrowDesktop.restored + }, { id: "layout-mobile-collapsed", - status: narrow.collapsed.pass ? "pass" : "blocked", + status: mobile.collapsed.pass ? "pass" : "blocked", + viewport: mobile.collapsed.viewport, summary: "Mobile default layout keeps the resource tree collapsed and preserves Agent input plus M3 tabs/controls.", - observations: narrow.collapsed + observations: mobile.collapsed }, { id: "layout-mobile-drawer", - status: narrow.drawer.pass ? "pass" : "blocked", + status: mobile.drawer.pass ? "pass" : "blocked", + viewport: mobile.drawer.viewport, summary: "Mobile drawer expansion makes resource controls reachable and can collapse back through the same accessible button.", - observations: narrow.drawer + observations: mobile.drawer + }, + { + id: "layout-gate-desktop", + status: desktop.gate.currentRoute.pass ? "pass" : "blocked", + viewport: desktop.gate.currentRoute.viewport, + summary: "Desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", + observations: desktop.gate.currentRoute + }, + { + id: "layout-gate-narrow-desktop", + status: narrowDesktop.gate.currentRoute.pass ? "pass" : "blocked", + viewport: narrowDesktop.gate.currentRoute.viewport, + summary: "Narrow desktop /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", + observations: narrowDesktop.gate.currentRoute + }, + { + id: "layout-gate-mobile", + status: mobile.gate.currentRoute.pass ? "pass" : "blocked", + viewport: mobile.gate.currentRoute.viewport, + summary: "Mobile /gate current internal page keeps root scroll locked and avoids incoherent visible overlap while #288 remains a future UI simplification.", + observations: mobile.gate.currentRoute + }, + { + id: "layout-issue-287-future-hardware-status-tabs", + status: allViewportCoverage(viewportResults, "issue287HardwareTabsReady") ? "pass" : "skip", + summary: "#287 hardware status tab set is not required for this #273 smoke until the wider live hardware workspace is implemented; current visible right-side containers are still checked for overflow.", + observations: summarizeCoverageGap(viewportResults, "issue287") + }, + { + id: "layout-issue-288-future-single-table-gate", + status: allViewportCoverage(viewportResults, "issue288SingleTableReady") ? "pass" : "skip", + summary: "#288 single-table internal review page is not required for this #273 smoke until that UI lands; current /gate route is checked only for outer-scroll and visible-layout regressions.", + observations: summarizeCoverageGap(viewportResults, "issue288") } ]; const blockers = checks .filter((check) => check.status !== "pass") + .filter((check) => check.status !== "skip") .map((check) => ({ type: "runtime_blocker", scope: check.id, status: "open", - summary: check.summary + summary: check.summary, + viewport: check.viewport ?? null, + selector: firstFailure(check)?.selector ?? null, + failureType: firstFailure(check)?.failureType ?? "blocked" + })); + const failures = checks.flatMap((check) => + (check.observations?.failures ?? []).map((failure) => ({ + checkId: check.id, + viewport: check.viewport ?? failure.viewport ?? null, + selector: failure.selector ?? null, + failureType: failure.failureType ?? "blocked", + summary: failure.summary ?? check.summary, + ...failure + })) + ); + const skipped = checks + .filter((check) => check.status === "skip") + .map((check) => ({ + checkId: check.id, + status: "skip", + failureType: "skip", + summary: check.summary, + observations: check.observations })); return { status: blockers.length === 0 ? "pass" : "blocked", task: "DC-DCSN-P0-2026-003", mode: "layout-browser", + sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), evidenceLevel: useLiveUrl ? blockers.length === 0 ? "DEV-LIVE-LAYOUT" : "BLOCKED" : "SOURCE", devLive: false, summary: useLiveUrl - ? "Live browser layout smoke verifies sidebar collapse/expand usability only; it is not M3 hardware acceptance." - : "Static local browser layout smoke verifies sidebar collapse/expand usability only; deployment still requires DEV live verification.", - refs: ["pikasTech/HWLAB#278", "pikasTech/HWLAB#273", "pikasTech/HWLAB#99"], + ? "Live browser layout smoke verifies workbench layout, hit targets, overflow, and /gate usability only; it is not M3 hardware acceptance." + : "Static local browser layout smoke verifies workbench layout, hit targets, overflow, and /gate usability only; deployment still requires DEV live verification.", + refs: ["pikasTech/HWLAB#273", "pikasTech/HWLAB#278", "pikasTech/HWLAB#287", "pikasTech/HWLAB#288", "pikasTech/HWLAB#99", "pikasTech/HWLAB#227"], + viewports: layoutViewports.map(({ id, width, height }) => ({ id, width, height })), checks, blockers, + failures, + skipped, + artifacts: { + screenshotDir: artifactRoot, + screenshots: Object.values(viewportResults).flatMap((result) => [ + ...(result.artifacts?.screenshots ?? []), + ...(result.gate?.currentRoute?.artifacts?.screenshots ?? []) + ]), + reportPath: args.reportPath ?? null + }, safety: { ...staticSafety(), sourceIsDevLive: false, layoutOnly: true, codeAgentPostSent: false, hardwareWriteApis: false, - statement: "Layout smoke opens the workbench and clicks sidebar controls only. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance." + hitTestMethod: "document.elementsFromPoint plus normal Playwright clicks without forced clicks", + statement: "Layout smoke opens the workbench, uses real Playwright clicks for sidebar controls, and samples hit targets with elementsFromPoint. It does not send Code Agent chat, call M3 IO, mutate DEV, or claim M3 DEV-LIVE hardware-loop acceptance." } }; } catch (error) { @@ -942,6 +1063,7 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { status: "blocked", task: "DC-DCSN-P0-2026-003", mode: "layout-browser", + sourceMode: layoutSourceMode, url, generatedAt: new Date().toISOString(), evidenceLevel: useLiveUrl ? "BLOCKED" : "SOURCE", @@ -953,9 +1075,25 @@ export async function runDevCloudWorkbenchLayoutSmoke(args = {}) { type: "runtime_blocker", scope: "layout-browser-smoke", status: "open", + summary: error.message, + selector: null, + failureType: "blocked", + viewport: null + } + ], + failures: [ + { + selector: null, + failureType: "blocked", + viewport: null, summary: error.message } ], + artifacts: { + screenshotDir: artifactRoot, + screenshots: [], + reportPath: args.reportPath ?? null + }, safety: staticSafety() }; } finally { @@ -3402,6 +3540,7 @@ export async function runDevCloudWorkbenchMobileSmoke() { } async function startStaticWebServer(options = {}) { + const rootDir = path.resolve(options.rootDir ?? webRoot); const server = http.createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (options.agentFixture && await handleLocalAgentFixtureApi({ request, response, url, options })) { @@ -3411,8 +3550,8 @@ async function startStaticWebServer(options = {}) { if (pathname === "/" || pathname === "/workbench" || pathname === "/gate" || pathname === "/diagnostics/gate" || pathname === "/help") { pathname = "/index.html"; } - const filePath = path.resolve(webRoot, pathname.replace(/^\/+/, "")); - if (!filePath.startsWith(webRoot)) { + const filePath = path.resolve(rootDir, pathname.replace(/^\/+/, "")); + if (!filePath.startsWith(rootDir)) { response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); response.end("forbidden"); return; @@ -3733,12 +3872,13 @@ async function inspectMobileWorkbench(page, { explorerOpen }) { }, { explorerOpen }); } -async function inspectWorkbenchLayoutViewport(browser, url, viewport) { +async function inspectWorkbenchLayoutViewport(browser, url, viewport, options = {}) { const page = await browser.newPage({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1, isMobile: viewport.isMobile === true }); + const artifacts = { screenshots: [] }; try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 }); await page.locator("#explorer-toggle").waitFor({ state: "visible", timeout: 12000 }); @@ -3749,6 +3889,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: null }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "mobile-collapsed")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const drawer = await inspectLayoutState(page, { @@ -3756,6 +3897,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: collapsed }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "mobile-drawer")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const recollapsed = await inspectLayoutState(page, { @@ -3770,7 +3912,8 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { keyTargetsReachable: recollapsed.keyTargetsReachable }; drawer.pass = drawer.pass && recollapsed.explorerCollapsed === true && recollapsed.keyTargetsReachable; - return { collapsed, drawer }; + const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options); + return { collapsed, drawer, gate, artifacts }; } const expanded = await inspectLayoutState(page, { @@ -3778,6 +3921,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: null }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "desktop-expanded")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const collapsed = await inspectLayoutState(page, { @@ -3785,6 +3929,7 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: expanded }); + artifacts.screenshots.push(...await captureLayoutScreenshots(page, options.artifactRoot, viewport, "desktop-collapsed")); await page.locator("#explorer-toggle").click(); await page.waitForFunction(() => !document.querySelector("[data-app-shell]")?.classList.contains("explorer-collapsed"), null, { timeout: 8000 }); const restored = await inspectLayoutState(page, { @@ -3792,7 +3937,28 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { viewport, compareTo: expanded }); - return { expanded, collapsed, restored }; + const gate = await inspectWorkbenchGateLayout(browser, url, viewport, options); + return { expanded, collapsed, restored, gate, artifacts }; + } finally { + await page.close(); + } +} + +async function inspectWorkbenchGateLayout(browser, url, viewport, options = {}) { + const page = await browser.newPage({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + isMobile: viewport.isMobile === true + }); + try { + await page.goto(new URL("/gate", url).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }); + await page.locator('[data-view="gate"]').waitFor({ state: "visible", timeout: 12000 }); + await page.waitForTimeout(150); + const currentRoute = await inspectGateLayoutState(page, { viewport, mode: "gate-current" }); + currentRoute.artifacts = { + screenshots: await captureGateScreenshots(page, options.artifactRoot, viewport) + }; + return { currentRoute }; } finally { await page.close(); } @@ -3800,16 +3966,44 @@ async function inspectWorkbenchLayoutViewport(browser, url, viewport) { async function inspectLayoutState(page, { mode, viewport, compareTo }) { return page.evaluate(async ({ mode, viewport, compareTo }) => { + const failures = []; + const skip = []; const selectorLabels = [ ["#explorer-toggle", "资源树折叠按钮"], ["#command-input", "Code Agent 输入"], ["#command-send", "发送"], + ["#m3-gateway-select", "M3 Gateway select"], + ["#m3-box-select", "M3 BOX select"], + ["#m3-port-select", "M3 port select"], + ["#m3-value-select", "M3 value select"], ["#tab-control", "M3 控制标签"], ["#tab-wiring", "M3 接线标签"], ["#tab-records", "可信记录标签"], ["#m3-write-do", "写入 DO1"], ["#m3-read-di", "读取 DI1"] ]; + const overflowSelectors = [ + "body", + "[data-app-shell]", + ".workbench-shell", + ".center-workspace", + "#command-form", + ".input-shell", + ".right-sidebar", + ".hardware-status", + "#hardware-list", + "#panel-control", + "#m3-control-form", + ".m3-control-actions" + ]; + const overlapPairs = [ + [".right-sidebar article.info-card", "#m3-control-form", "M3 control info-card vs DO/DI form"], + [".right-sidebar article.info-card", ".m3-control-actions", "M3 control info-card vs actions"], + ["#m3-control-form", "#control-list", "M3 form vs control info list"], + [".m3-control-actions", "#control-list", "M3 actions vs control info list"], + [".hardware-status", ".side-workspace", "hardware status vs side workspace"], + ["#command-form", ".right-sidebar", "Code Agent command form vs right sidebar"] + ]; const drawerSelectorLabels = [ [".quick-actions button", "资源树快捷操作"], ["#resource-tree summary", "资源树分组"], @@ -3830,7 +4024,9 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { visibility: style.visibility, overflow: style.overflow, overflowY: style.overflowY, + clientWidth: element.clientWidth, clientHeight: element.clientHeight, + scrollWidth: element.scrollWidth, scrollHeight: element.scrollHeight, text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 120) ?? "" }; @@ -3842,34 +4038,98 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { }; const ownsHit = (element, hit) => hit === element || element.contains(hit); const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; - const inspectHitTarget = (selector, label) => { + const nearestScrollableAncestor = (element) => { + let current = element.parentElement; + while (current && current !== document.documentElement) { + const style = getComputedStyle(current); + const scrollable = current.scrollHeight > current.clientHeight + 2 && !["visible", "clip"].includes(style.overflowY); + if (scrollable) return current; + current = current.parentElement; + } + return null; + }; + const scrollIntoNearestContainer = async (element) => { + const container = nearestScrollableAncestor(element); + if (!container) { + element.scrollIntoView({ block: "center", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + return null; + } + const elementBox = element.getBoundingClientRect(); + const containerBox = container.getBoundingClientRect(); + const inset = Math.max(6, Math.min(24, (container.clientHeight - Math.min(elementBox.height, container.clientHeight)) / 2)); + container.scrollTop += elementBox.top - containerBox.top - inset; + await new Promise((resolve) => requestAnimationFrame(resolve)); + return container; + }; + const clipBoxForElement = (element) => { + let visible = { + left: 0, + top: 0, + right: window.innerWidth, + bottom: window.innerHeight + }; + let current = element.parentElement; + while (current && current !== document.documentElement) { + const style = getComputedStyle(current); + if (!["visible", "clip"].includes(style.overflow) || !["visible", "clip"].includes(style.overflowY) || !["visible", "clip"].includes(style.overflowX)) { + const box = current.getBoundingClientRect(); + visible = { + left: Math.max(visible.left, box.left), + top: Math.max(visible.top, box.top), + right: Math.min(visible.right, box.right), + bottom: Math.min(visible.bottom, box.bottom) + }; + } + current = current.parentElement; + } + return visible; + }; + const inspectHitTarget = async (selector, label) => { const element = document.querySelector(selector); if (!element) return { selector, label, ok: false, missing: true }; - element.scrollIntoView({ block: "center", inline: "nearest" }); + const scrollContainer = await scrollIntoNearestContainer(element); const box = element.getBoundingClientRect(); - const cx = box.left + box.width / 2; - const cy = box.top + box.height / 2; - const hit = document.elementFromPoint(cx, cy); + const clipBox = clipBoxForElement(element); + const visibleBox = { + left: Math.max(box.left, clipBox.left), + top: Math.max(box.top, clipBox.top), + right: Math.min(box.right, clipBox.right), + bottom: Math.min(box.bottom, clipBox.bottom) + }; + const visibleWidth = Math.max(0, visibleBox.right - visibleBox.left); + const visibleHeight = Math.max(0, visibleBox.bottom - visibleBox.top); + const cx = visibleBox.left + visibleWidth / 2; + const cy = visibleBox.top + visibleHeight / 2; + const stack = document.elementsFromPoint(cx, cy); + const hit = stack[0] ?? null; const style = getComputedStyle(element); + const ok = + style.display !== "none" && + style.visibility !== "hidden" && + box.width > 0 && + box.height > 0 && + visibleWidth > 0 && + visibleHeight > 0 && + cx >= 0 && + cx <= window.innerWidth && + cy >= 0 && + cy <= window.innerHeight && + stack.some((candidate) => ownsHit(element, candidate)); return { selector, label, - ok: - style.display !== "none" && - style.visibility !== "hidden" && - box.width > 0 && - box.height > 0 && - cx >= 0 && - cx <= window.innerWidth && - cy >= 0 && - cy <= window.innerHeight && - ownsHit(element, hit), + ok, center: { x: cx, y: cy }, box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height }, - hit: hitLabel(hit) + visibleBox, + clipping: clipBox, + scrollContainer: scrollContainer ? scrollContainer.id ? `#${scrollContainer.id}` : scrollContainer.className ? `.${String(scrollContainer.className).trim().replace(/\s+/gu, ".")}` : scrollContainer.tagName.toLowerCase() : null, + hit: hitLabel(hit), + hitStack: stack.slice(0, 5).map(hitLabel) }; }; - const inspectFirstHit = (selector, label) => { + const inspectFirstHit = async (selector, label) => { const element = document.querySelector(selector); if (!element) return { selector, label, ok: false, missing: true }; return inspectHitTarget(selector, label); @@ -3908,6 +4168,10 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { center: boxFor(".center-workspace"), right: boxFor(".right-sidebar"), commandBar: boxFor("#command-form"), + commandInput: boxFor("#command-input"), + commandSend: boxFor("#command-send"), + hardwareStatus: boxFor(".hardware-status"), + hardwareList: boxFor("#hardware-list"), controlPanel: boxFor("#panel-control"), m3Form: boxFor("#m3-control-form"), m3Actions: boxFor(".m3-control-actions"), @@ -3927,8 +4191,14 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { rootAfterScrollAttempt.windowScrollY === 0 && rootAfterScrollAttempt.htmlScrollTop === 0 && rootAfterScrollAttempt.bodyScrollTop === 0; - const keyTargets = selectorLabels.map(([selector, label]) => inspectHitTarget(selector, label)); - const drawerTargets = drawerSelectorLabels.map(([selector, label]) => inspectFirstHit(selector, label)); + const keyTargets = []; + for (const [selector, label] of selectorLabels) { + keyTargets.push(await inspectHitTarget(selector, label)); + } + const drawerTargets = []; + for (const [selector, label] of drawerSelectorLabels) { + drawerTargets.push(await inspectFirstHit(selector, label)); + } const blockedKeyTargets = keyTargets.filter((target) => !target.ok); const blockedDrawerTargets = drawerTargets.filter((target) => !target.ok); const explorerCollapsed = shell?.classList.contains("explorer-collapsed") === true; @@ -3941,6 +4211,18 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { ); const isDesktop = viewport.width >= 861; const isMobile = !isDesktop; + const requireKeyTargets = !(isMobile && mode === "mobile-drawer"); + if (requireKeyTargets) { + for (const target of blockedKeyTargets) { + failures.push({ + failureType: target.missing ? "blocked/skip" : "covered-hit-target", + selector: target.selector, + viewport, + summary: `${target.label} is not center-click reachable; hit=${target.hit ?? "none"}`, + target + }); + } + } const centerWidthDelta = compareTo?.boxes?.center?.width && boxes.center ? boxes.center.width - compareTo.boxes.center.width : null; @@ -3954,7 +4236,77 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { m3ActionsControlList: boxesOverlap(boxes.m3Actions, boxes.controlList, -1), topbarCommandBar: boxesOverlap(boxes.topbar, boxes.commandBar, -1) }; + const semanticOverlapChecks = overlapPairs.map(([aSelector, bSelector, label]) => { + const a = boxFor(aSelector); + const b = boxFor(bSelector); + const overlaps = boxesOverlap(a, b, -1); + if (overlaps) { + failures.push({ + failureType: "overlap", + selector: `${aSelector} <-> ${bSelector}`, + viewport, + summary: `${label} overlap`, + boxes: { [aSelector]: a, [bSelector]: b } + }); + } + return { + label, + selectors: [aSelector, bSelector], + overlaps, + boxes: { [aSelector]: a, [bSelector]: b } + }; + }); const noIncoherentOverlap = Object.values(overlapChecks).every((value) => value === false); + if (!noIncoherentOverlap) { + for (const [key, value] of Object.entries(overlapChecks)) { + if (value) { + failures.push({ + failureType: "overlap", + selector: key, + viewport, + summary: `layout region overlap detected: ${key}` + }); + } + } + } + const overflowChecks = overflowSelectors.map((selector) => { + const element = selector === "body" ? document.body : document.querySelector(selector); + if (!element) { + return { selector, exists: false, ok: true, skipped: true }; + } + const style = getComputedStyle(element); + const allowHorizontalScroll = selector === ".activity-rail" || selector === ".table-wrap"; + const horizontalOverflow = element.scrollWidth > element.clientWidth + 2; + const verticalOverflow = element.scrollHeight > element.clientHeight + 2; + const isRootSelector = selector === "body" || selector === "[data-app-shell]" || selector === ".workbench-shell"; + const ok = isRootSelector + ? !horizontalOverflow && !verticalOverflow + : !horizontalOverflow || allowHorizontalScroll || ["auto", "scroll"].includes(style.overflowX); + const result = { + selector, + exists: true, + ok, + horizontalOverflow, + verticalOverflow, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + overflowX: style.overflowX, + overflowY: style.overflowY + }; + if (!ok) { + failures.push({ + failureType: "overflow", + selector, + viewport, + summary: `${selector} overflow regression: scrollWidth=${element.scrollWidth}, clientWidth=${element.clientWidth}, scrollHeight=${element.scrollHeight}, clientHeight=${element.clientHeight}`, + overflow: result + }); + } + return result; + }); + const overflowOk = overflowChecks.every((check) => check.ok); const sidePanelScroll = await scrollableProbe("#panel-control"); const sidePanelOverflowUsable = sidePanelScroll.exists && (sidePanelScroll.scrolls || sidePanelScroll.overflowY !== "hidden"); const toggle = { @@ -3973,6 +4325,15 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { const drawerReachable = isMobile && mode === "mobile-drawer" ? drawerTargets.length >= 3 && blockedDrawerTargets.length === 0 : true; + for (const target of isMobile && mode === "mobile-drawer" ? blockedDrawerTargets : []) { + failures.push({ + failureType: target.missing ? "blocked/skip" : "covered-hit-target", + selector: target.selector, + viewport, + summary: `${target.label} drawer target is not reachable; hit=${target.hit ?? "none"}`, + target + }); + } const collapsedExpected = mode === "desktop-collapsed" || mode === "mobile-collapsed" || mode === "mobile-recollapsed"; const expandedExpected = mode === "desktop-expanded" || mode === "desktop-restored" || mode === "mobile-drawer"; const collapsedStateMatches = collapsedExpected @@ -3989,10 +4350,117 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { ? explorerCollapsed === true : true; const keyTargetsReachable = blockedKeyTargets.length === 0; - const keyTargetsRequirement = mode === "mobile-drawer" ? true : keyTargetsReachable; + const keyTargetsRequirement = requireKeyTargets ? keyTargetsReachable : true; + if (!collapsedStateMatches) { + failures.push({ + failureType: "blocked/skip", + selector: "#explorer-toggle", + viewport, + summary: `Explorer toggle state/Chinese label mismatch in ${mode}: text=${toggle.text}, ariaLabel=${toggle.ariaLabel}, ariaExpanded=${toggle.ariaExpanded}, ariaPressed=${toggle.ariaPressed}.`, + toggle, + expected: collapsedExpected + ? { + explorerCollapsed: true, + text: "展开资源树", + ariaLabel: "展开左侧资源树", + ariaExpanded: "false", + ariaPressed: "true" + } + : { + explorerCollapsed: false, + text: "收起资源树", + ariaLabel: "收起左侧资源树", + ariaExpanded: "true", + ariaPressed: "false" + } + }); + } + if (!explorerStateMatches) { + failures.push({ + failureType: "blocked/skip", + selector: "#resource-explorer", + viewport, + summary: `Explorer visibility did not match expected ${collapsedExpected ? "collapsed" : "expanded"} state in ${mode}.`, + explorerCollapsed, + explorerVisible, + explorerBox: boxes.explorer + }); + } + if (!collapsedWidthGain) { + failures.push({ + failureType: "overflow", + selector: ".center-workspace", + viewport, + summary: `Collapsed desktop layout did not release enough width to the center workspace; centerWidthDelta=${centerWidthDelta}.`, + centerWidthDelta, + centerBox: boxes.center + }); + } + if (!restoredWidth) { + failures.push({ + failureType: "overflow", + selector: ".center-workspace", + viewport, + summary: `Restored desktop layout did not return center width near the expanded baseline; centerWidthDelta=${centerWidthDelta}.`, + centerWidthDelta, + centerBox: boxes.center + }); + } + if (!drawerReachable) { + failures.push({ + failureType: "covered-hit-target", + selector: "#resource-explorer", + viewport, + summary: "Mobile resource drawer opened but its quick actions/resource rows were not reachable.", + blockedDrawerTargets + }); + } + if (!defaultCollapsedMobile) { + failures.push({ + failureType: "blocked/skip", + selector: "[data-app-shell]", + viewport, + summary: "Mobile default workbench did not start with the resource explorer collapsed.", + explorerCollapsed + }); + } + if (!rootScrollLocked) { + failures.push({ + failureType: "outer-scroll-regression", + selector: "html/body/.workbench-shell", + viewport, + summary: "Outer html/body/workbench shell scrolled or exceeded viewport after scroll attempt.", + rootAfterScrollAttempt, + html: boxForElement(document.documentElement), + body: boxForElement(document.body), + shell: boxes.shell + }); + } + if (!sidePanelOverflowUsable) { + failures.push({ + failureType: "overflow", + selector: "#panel-control", + viewport, + summary: "M3 control panel does not expose usable internal scrolling when needed.", + sidePanelScroll + }); + } + const issue287HardwareTabsReady = ["总览", "Gateway-SIMU", "BOX-SIMU-1", "BOX-SIMU-2", "Patch Panel"].every((label) => + Boolean([...document.querySelectorAll(".hardware-status button, .hardware-status [role='tab'], .hardware-status .side-tab")].find((item) => item.textContent?.includes(label))) + ); + if (!issue287HardwareTabsReady) { + skip.push({ + failureType: "skip", + selector: ".hardware-status", + viewport, + summary: "#287 hardware status tabs are not visible in the current DOM; current container overflow is still covered." + }); + } const pass = rootScrollLocked && noIncoherentOverlap && + semanticOverlapChecks.every((check) => !check.overlaps) && + overflowOk && keyTargetsRequirement && collapsedStateMatches && explorerStateMatches && @@ -4024,11 +4492,303 @@ async function inspectLayoutState(page, { mode, viewport, compareTo }) { blockedDrawerTargets, sidePanelScroll, sidePanelOverflowUsable, - defaultCollapsedMobile + semanticOverlapChecks, + overflowChecks, + defaultCollapsedMobile, + issue287HardwareTabsReady, + issue288SingleTableReady: false, + skip, + failures }; }, { mode, viewport, compareTo }); } +async function inspectGateLayoutState(page, { mode, viewport }) { + return page.evaluate(async ({ mode, viewport }) => { + const failures = []; + const skip = []; + const boxForElement = (element) => { + if (!element) return null; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + width: box.width, + height: box.height, + display: style.display, + visibility: style.visibility, + overflowX: style.overflowX, + overflowY: style.overflowY, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + text: element.textContent?.replace(/\s+/gu, " ").trim().slice(0, 160) ?? "" + }; + }; + const boxFor = (selector) => boxForElement(document.querySelector(selector)); + const boxesOverlap = (a, b, gap = 0) => { + if (!a || !b || a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) return false; + return !(a.right + gap <= b.left || b.right + gap <= a.left || a.bottom + gap <= b.top || b.bottom + gap <= a.top); + }; + const ownsHit = (element, hit) => hit === element || element.contains(hit); + const hitLabel = (hit) => hit ? `${hit.tagName.toLowerCase()}${hit.id ? `#${hit.id}` : ""}${hit.className ? `.${String(hit.className).trim().replace(/\s+/gu, ".")}` : ""}` : "none"; + const hitTarget = (selector, label) => { + const element = document.querySelector(selector); + if (!element) { + const missing = { selector, label, ok: false, missing: true }; + failures.push({ + failureType: "blocked/skip", + selector, + viewport, + summary: `${label} is missing on /gate`, + target: missing + }); + return missing; + } + const box = element.getBoundingClientRect(); + const cx = box.left + box.width / 2; + const cy = box.top + Math.min(box.height / 2, 24); + const stack = document.elementsFromPoint(cx, cy); + const ok = + box.width > 0 && + box.height > 0 && + cx >= 0 && + cx <= window.innerWidth && + cy >= 0 && + cy <= window.innerHeight && + stack.some((candidate) => ownsHit(element, candidate)); + const target = { + selector, + label, + ok, + center: { x: cx, y: cy }, + hit: hitLabel(stack[0] ?? null), + hitStack: stack.slice(0, 5).map(hitLabel), + box: { left: box.left, top: box.top, right: box.right, bottom: box.bottom, width: box.width, height: box.height } + }; + if (!ok) { + failures.push({ + failureType: "covered-hit-target", + selector, + viewport, + summary: `${label} is not user-reachable on /gate; hit=${target.hit}`, + target + }); + } + return target; + }; + + window.scrollTo(0, 240); + document.documentElement.scrollTop = 240; + document.body.scrollTop = 240; + await new Promise((resolve) => requestAnimationFrame(resolve)); + const gate = document.querySelector('[data-view="gate"]'); + const workspace = document.querySelector('[data-view="workspace"]'); + const shell = document.querySelector("[data-app-shell]"); + const html = boxForElement(document.documentElement); + const body = boxForElement(document.body); + const shellBox = boxFor("[data-app-shell]"); + const gateBox = boxFor('[data-view="gate"]'); + const rootAfterScrollAttempt = { + windowScrollY: window.scrollY, + htmlScrollTop: document.documentElement.scrollTop, + bodyScrollTop: document.body.scrollTop + }; + const rootScrollLocked = + getComputedStyle(document.documentElement).overflow === "hidden" && + getComputedStyle(document.body).overflow === "hidden" && + document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2 && + document.body.scrollHeight <= document.body.clientHeight + 2 && + rootAfterScrollAttempt.windowScrollY === 0 && + rootAfterScrollAttempt.htmlScrollTop === 0 && + rootAfterScrollAttempt.bodyScrollTop === 0; + if (!rootScrollLocked) { + failures.push({ + failureType: "outer-scroll-regression", + selector: "html/body/.workbench-shell", + viewport, + summary: "/gate route allowed page-level scrolling.", + rootAfterScrollAttempt, + html, + body, + shell: shellBox + }); + } + const hitTargets = [ + hitTarget('[data-route="workspace"]', "工作台"), + hitTarget('[data-route="gate"]', "内部复核"), + hitTarget('[data-route="help"]', "使用说明") + ]; + const gateChildren = [...document.querySelectorAll('[data-view="gate"] > *')].map((element, index) => ({ + selector: `[data-view="gate"] > :nth-child(${index + 1})`, + box: boxForElement(element) + })); + const visibleOverlapPairs = []; + for (let i = 0; i < gateChildren.length; i += 1) { + for (let j = i + 1; j < gateChildren.length; j += 1) { + const a = gateChildren[i]; + const b = gateChildren[j]; + if (boxesOverlap(a.box, b.box, -1)) { + visibleOverlapPairs.push([a.selector, b.selector]); + failures.push({ + failureType: "overlap", + selector: `${a.selector} <-> ${b.selector}`, + viewport, + summary: "/gate visible sections overlap.", + boxes: { [a.selector]: a.box, [b.selector]: b.box } + }); + } + } + } + const gateHorizontalOverflow = Boolean(gate && gate.scrollWidth > gate.clientWidth + 2); + if (gateHorizontalOverflow) { + failures.push({ + failureType: "overflow", + selector: '[data-view="gate"]', + viewport, + summary: `/gate horizontal overflow: scrollWidth=${gate.scrollWidth}, clientWidth=${gate.clientWidth}`, + overflow: { + scrollWidth: gate.scrollWidth, + clientWidth: gate.clientWidth, + overflowX: getComputedStyle(gate).overflowX + } + }); + } + const tableCount = document.querySelectorAll('[data-view="gate"] table').length; + const workspacePanels = document.querySelectorAll('[data-view="gate"] .workspace-panel').length; + const complexLayoutCount = document.querySelectorAll('[data-view="gate"] .gate-grid, [data-view="gate"] .gate-split, [data-view="gate"] .milestone-grid').length; + const issue288SingleTableReady = tableCount === 1 && workspacePanels <= 1 && complexLayoutCount === 0; + if (!issue288SingleTableReady) { + skip.push({ + failureType: "skip", + selector: '[data-view="gate"]', + viewport, + summary: `#288 single-table gate page not yet implemented: tableCount=${tableCount}, workspacePanels=${workspacePanels}, complexLayoutCount=${complexLayoutCount}` + }); + } + const pass = + window.location.pathname.replace(/\/+$/, "") === "/gate" && + gate?.hidden === false && + workspace?.hidden === true && + shell !== null && + rootScrollLocked && + hitTargets.every((target) => target.ok) && + !gateHorizontalOverflow && + visibleOverlapPairs.length === 0; + return { + mode, + viewport, + pass, + pathname: window.location.pathname, + workspaceHidden: workspace ? workspace.hidden : null, + gateHidden: gate ? gate.hidden : null, + rootScrollLocked, + rootAfterScrollAttempt, + boxes: { + html, + body, + shell: shellBox, + gate: gateBox + }, + hitTargets, + visibleOverlapPairs, + gateHorizontalOverflow, + tableCount, + workspacePanels, + complexLayoutCount, + issue288SingleTableReady, + issue287HardwareTabsReady: false, + failures, + skip + }; + }, { mode, viewport }); +} + +async function captureLayoutScreenshots(page, artifactRoot, viewport, stateId) { + if (!artifactRoot) return []; + return [ + await captureElementScreenshot(page, ".right-sidebar", artifactRoot, viewport, `${stateId}-right-sidebar`), + await captureElementScreenshot(page, "#m3-control-form", artifactRoot, viewport, `${stateId}-m3-control-form`), + await captureElementScreenshot(page, "#command-form", artifactRoot, viewport, `${stateId}-code-agent-input`) + ].filter(Boolean); +} + +async function captureGateScreenshots(page, artifactRoot, viewport) { + if (!artifactRoot) return []; + return [ + await captureElementScreenshot(page, '[data-view="gate"]', artifactRoot, viewport, "gate-current") + ].filter(Boolean); +} + +async function captureElementScreenshot(page, selector, artifactRoot, viewport, label) { + const locator = page.locator(selector).first(); + const count = await locator.count(); + if (count === 0) return null; + const safeLabel = label.replace(/[^a-z0-9_-]+/giu, "-").replace(/^-|-$/gu, "").toLowerCase(); + const filePath = path.join(artifactRoot, `${viewport.width}x${viewport.height}-${safeLabel}.png`); + try { + await locator.screenshot({ path: filePath, timeout: 5000 }); + return { + selector, + viewport: { width: viewport.width, height: viewport.height }, + label, + path: filePath + }; + } catch (error) { + return { + selector, + viewport: { width: viewport.width, height: viewport.height }, + label, + path: filePath, + status: "blocked", + failureType: "screenshot-diff", + summary: `screenshot capture failed: ${error.message}` + }; + } +} + +function firstFailure(check) { + return check?.observations?.failures?.[0] ?? null; +} + +function allViewportCoverage(viewportResults, fieldName) { + return Object.values(viewportResults).every((result) => + Boolean(result.expanded?.[fieldName] ?? result.collapsed?.[fieldName] ?? result.gate?.currentRoute?.[fieldName]) + ); +} + +function summarizeCoverageGap(viewportResults, issueKey) { + const fieldName = issueKey === "issue288" ? "issue288SingleTableReady" : "issue287HardwareTabsReady"; + return { + status: allViewportCoverage(viewportResults, fieldName) ? "pass" : "skip", + coverage: Object.fromEntries( + Object.entries(viewportResults).map(([id, result]) => [ + id, + { + viewport: result.expanded?.viewport ?? result.collapsed?.viewport ?? result.gate?.currentRoute?.viewport ?? null, + covered: Boolean(result.expanded?.[fieldName] ?? result.collapsed?.[fieldName] ?? result.gate?.currentRoute?.[fieldName]), + currentVisibleContainersChecked: true, + skip: [ + ...(result.expanded?.skip ?? []), + ...(result.collapsed?.skip ?? []), + ...(result.gate?.currentRoute?.skip ?? []) + ].filter((item) => issueKey === "issue288" + ? item.summary?.includes("#288") + : item.summary?.includes("#287")) + } + ]) + ) + }; +} + +function layoutArtifactTimestamp(date = new Date()) { + return date.toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z"); +} + async function inspectWorkbenchScrollContract(page, { panelSelector }) { return page.evaluate(async ({ panelSelector }) => { function metrics(selector) {