From 8a90386746ce7c9a178a25a5edf935c5a7852437 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 09:17:02 +0000 Subject: [PATCH] Check node diagnostic codes across Core and Web --- apps/web/src/lib/sandbox-diagnostic.test.ts | 14 ++++- apps/web/src/lib/sandbox-diagnostic.ts | 16 +----- .../agents-api/node-generation-protocol.md | 2 + .../agents-client/src/sandbox-client.test.ts | 18 +++++- packages/agents-client/src/sandbox-client.ts | 33 ++++++----- .../internal/sandbox/node_diagnostic_test.go | 42 ++++++++++++++ .../sandbox/testdata/node-diagnostics.json | 10 ++++ .../store/node_diagnostic_contract_test.go | 55 +++++++++++++++++++ 8 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 services/core/internal/sandbox/node_diagnostic_test.go create mode 100644 services/core/internal/sandbox/testdata/node-diagnostics.json create mode 100644 services/core/internal/store/node_diagnostic_contract_test.go diff --git a/apps/web/src/lib/sandbox-diagnostic.test.ts b/apps/web/src/lib/sandbox-diagnostic.test.ts index 781a65087..6c26cfb9f 100644 --- a/apps/web/src/lib/sandbox-diagnostic.test.ts +++ b/apps/web/src/lib/sandbox-diagnostic.test.ts @@ -1,4 +1,4 @@ -import type { SandboxNodeDiagnostic } from "@oac/agents-client"; +import { sandboxNodeDiagnostics, type SandboxNodeDiagnostic } from "@oac/agents-client"; import { describe, expect, it } from "vitest"; import { nodeProviderDiagnostic, sandboxDiagnosticMessage } from "./sandbox-diagnostic"; @@ -48,3 +48,15 @@ describe("sandbox diagnostics", () => { } }); }); + +describe("shared node readiness diagnostics", () => { + it.each(sandboxNodeDiagnostics)("preserves %s with localized messages", (diagnostic) => { + expect(nodeProviderDiagnostic({ online: true, provider_ready: false, diagnostic })).toBe(diagnostic); + const english = sandboxDiagnosticMessage(diagnostic, "en"); + const chinese = sandboxDiagnosticMessage(diagnostic, "zh"); + expect(english?.label).not.toBe("Sandbox state needs attention"); + expect(english?.advice).not.toBe("Inspect the assigned node and resource, then refresh."); + expect(chinese?.label).not.toBe(english?.label); + expect(chinese?.advice).not.toBe(english?.advice); + }); +}); diff --git a/apps/web/src/lib/sandbox-diagnostic.ts b/apps/web/src/lib/sandbox-diagnostic.ts index f800e0c59..20caf6db5 100644 --- a/apps/web/src/lib/sandbox-diagnostic.ts +++ b/apps/web/src/lib/sandbox-diagnostic.ts @@ -1,4 +1,4 @@ -import type { SandboxNode } from "@oac/agents-client"; +import { normalizeSandboxNodeDiagnostic, type SandboxNode } from "@oac/agents-client"; import { translate, type Locale } from "./locale"; import type { MessageKey } from "./locale-strings"; export interface SandboxDiagnosticMessage { label: string; advice: string } @@ -55,18 +55,6 @@ const diagnostics: Record = { }, }; -/** The fixed codes Core reports for a node whose provider is not ready. */ -const nodeDiagnostics: ReadonlySet = new Set([ - "provider_unavailable", - "docker_unavailable", - "docker_limits_unsupported", - "runtime_download_failed", - "runtime_image_unavailable", - "kvm_unavailable", - "microsandbox_artifacts_unavailable", - "capacity_insufficient", -]); - /** * Why an online node's provider is not ready, as one fixed code; an unknown * value reads as provider_unavailable. Empty while the provider is ready, and @@ -74,7 +62,7 @@ const nodeDiagnostics: ReadonlySet = new Set([ */ export function nodeProviderDiagnostic(node: Pick): string { if (!node.online || (node.provider_ready && !node.diagnostic)) return ""; - return node.diagnostic && nodeDiagnostics.has(node.diagnostic) ? node.diagnostic : "provider_unavailable"; + return normalizeSandboxNodeDiagnostic(node.diagnostic ?? ""); } export function sandboxDiagnosticMessage(value?: string, locale: Locale = "en"): SandboxDiagnosticMessage | null { diff --git a/contracts/agents-api/node-generation-protocol.md b/contracts/agents-api/node-generation-protocol.md index b40e511e2..1cebfedec 100644 --- a/contracts/agents-api/node-generation-protocol.md +++ b/contracts/agents-api/node-generation-protocol.md @@ -196,6 +196,8 @@ native executable is missing and whose import may have started remains retained; missing files do not prove native absence. Receipt/store history is never erased using an empty native inventory. +The diagnostic codes are authored in `services/core/internal/sandbox/node_diagnostic.go`. The shared `services/core/internal/sandbox/testdata/node-diagnostics.json` fixture checks the Go mapping, OpenAPI source annotations and generated enums, and the TypeScript client declaration. Web uses the client normalizer and checks localized messages for every declared code. Update these projections with a code change; unknown codes normalize to `provider_unavailable`. + Preparation diagnostics preserve fixed typed causes. Only artifact transfer, checksum or release-provenance failures report `runtime_download_failed`. A private preparer exit category communicates that class without parsing stderr; provider, diff --git a/packages/agents-client/src/sandbox-client.test.ts b/packages/agents-client/src/sandbox-client.test.ts index 24564e733..ca9a8fcb8 100644 --- a/packages/agents-client/src/sandbox-client.test.ts +++ b/packages/agents-client/src/sandbox-client.test.ts @@ -1,6 +1,8 @@ import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { AgentCoreError, OpenAIAgentsClient } from "./client"; -import { SandboxAdminClient, type SandboxNode } from "./sandbox-client"; +import { SandboxAdminClient, normalizeSandboxNodeDiagnostic, sandboxNodeDiagnostics, type SandboxNode } from "./sandbox-client"; + +import nodeDiagnosticFixture from "../../../services/core/internal/sandbox/testdata/node-diagnostics.json"; function response(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status }); } @@ -397,3 +399,17 @@ it.each(["unknown", "preparing", "failed", "update_required"])("does not erase o const client = new SandboxAdminClient({ fetch: async () => response({ data: [value] }) }); expect((await client.listNodes()).data[0]).toEqual(value); }); + +describe("shared node diagnostic contract", () => { + it("checks the client declaration against the Go diagnostic fixture", () => { + expect([...sandboxNodeDiagnostics].sort()).toEqual([...nodeDiagnosticFixture].sort()); + expect(normalizeSandboxNodeDiagnostic("future_code")).toBe("provider_unavailable"); + }); + it.each(nodeDiagnosticFixture)("preserves %s through node and rollout projections", async (diagnostic) => { + const value = { ...node, provider_ready: false, diagnostic, rollout: { state: "failed", ready_generation: 1, diagnostic } }; + const client = new SandboxAdminClient({ baseUrl: "https://core.example", token: "test", fetch: async () => response({ data: [value] }) }); + const result = await client.listNodes(); + expect(result.data[0]?.diagnostic).toBe(diagnostic); + expect(result.data[0]?.rollout.diagnostic).toBe(diagnostic); + }); +}); diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 72f56b5fa..2a5c87f83 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -4,17 +4,25 @@ import { hasOwn, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } fr import type { ReadOptions } from "./types"; export type SandboxDiagnostic = "" | "node_unavailable" | "resource_missing" | "compute_unconfirmed" | "ownership_mismatch" | "provider_unavailable"; +/** Checked against Core's shared node-diagnostics.json fixture. */ +export const sandboxNodeDiagnostics = [ + "provider_unavailable", + "docker_unavailable", + "docker_limits_unsupported", + "runtime_download_failed", + "runtime_image_unavailable", + "kvm_unavailable", + "microsandbox_artifacts_unavailable", + "capacity_insufficient", +] as const; /** Fixed reason a node's provider is not ready. Core omits the field while the provider is ready, so read it as falsy (undefined) then. The client reads an unknown future value as provider_unavailable. */ -export type SandboxNodeDiagnostic = - | "" - | "provider_unavailable" - | "docker_unavailable" - | "docker_limits_unsupported" - | "runtime_download_failed" - | "runtime_image_unavailable" - | "kvm_unavailable" - | "microsandbox_artifacts_unavailable" - | "capacity_insufficient"; +export type SandboxNodeDiagnostic = "" | typeof sandboxNodeDiagnostics[number]; +const nodeDiagnostics: ReadonlySet = new Set(sandboxNodeDiagnostics); + +/** Keep a known readiness cause; never expose unclassified node-supplied text. */ +export function normalizeSandboxNodeDiagnostic(value: string): Exclude { + return nodeDiagnostics.has(value) ? value as Exclude : "provider_unavailable"; +} export type SandboxProvider = "docker" | "microsandbox" | "e2b"; /** CPU and MiB limits for each sandbox, not node concurrency. */ @@ -240,7 +248,7 @@ function projectNodeRollout(value: unknown, online: unknown): SandboxNodeRollout valid(["ready", "preparing", "failed", "update_required", "unknown"].includes(rollout.state as string) && nullable(isNonnegativeInteger)(rollout.ready_generation) && (online !== false || rollout.state === "unknown") && (rollout.state !== "ready" || rollout.ready_generation !== null) && (rollout.diagnostic === undefined || (typeof rollout.diagnostic === "string" && rollout.diagnostic !== "" && rollout.state === "failed"))); - return { ...rollout, ...(rollout.diagnostic !== undefined ? { diagnostic: nodeDiagnostics.has(rollout.diagnostic as string) ? rollout.diagnostic : "provider_unavailable" } : {}) } as unknown as SandboxNodeRollout; + return { ...rollout, ...(rollout.diagnostic !== undefined ? { diagnostic: normalizeSandboxNodeDiagnostic(rollout.diagnostic as string) } : {}) } as unknown as SandboxNodeRollout; } /** Configured deployments carry a validated public configuration and observation object. */ function projectDeployment(value: unknown): SandboxDeployment { @@ -265,7 +273,6 @@ function projectDeployment(value: unknown): SandboxDeployment { const nodeFields = ["rollout", "id", "name", "provider", "online", "provider_ready", "cpu_count", "available_memory_bytes", "available_disk_bytes", "running", "snapshots", "last_seen_at", "max_active", "max_retained", "active", "reserved", "retained", "cleanup_pending", "created_at", "core_url", "enrollment_id"]; -const nodeDiagnostics = new Set(["provider_unavailable", "docker_unavailable", "docker_limits_unsupported", "runtime_download_failed", "runtime_image_unavailable", "kvm_unavailable", "microsandbox_artifacts_unavailable", "capacity_insufficient"]); /** Core omits an empty `diagnostic`, so a present one is a code; an unknown code reads as provider_unavailable. */ function projectNode(node: Record): SandboxNode { const { diagnostic, rollout, ...rest } = node; @@ -276,7 +283,7 @@ function projectNode(node: Record): SandboxNode { nullable(timestamp)(node.last_seen_at) && timestamp(node.created_at) && (node.enrollment_id === null || typeof node.enrollment_id === "string") && (diagnostic === undefined || (typeof diagnostic === "string" && diagnostic !== ""))); if (diagnostic === undefined) return { ...fields } as unknown as SandboxNode; - return { ...fields, diagnostic: nodeDiagnostics.has(diagnostic as string) ? diagnostic : "provider_unavailable" } as unknown as SandboxNode; + return { ...fields, diagnostic: normalizeSandboxNodeDiagnostic(diagnostic as string) } as unknown as SandboxNode; } function projectNodeList(value: unknown): { data: SandboxNode[] } { const list = members(value, ["data"]); diff --git a/services/core/internal/sandbox/node_diagnostic_test.go b/services/core/internal/sandbox/node_diagnostic_test.go new file mode 100644 index 000000000..e7a9602eb --- /dev/null +++ b/services/core/internal/sandbox/node_diagnostic_test.go @@ -0,0 +1,42 @@ +package sandbox + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "testing" +) + +func TestNodeDiagnosticContract(t *testing.T) { + raw, err := os.ReadFile("testdata/node-diagnostics.json") + if err != nil { + t.Fatal(err) + } + var fixture []string + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + codes := []string{NodeProviderUnavailable} + for _, diagnostic := range nodeDiagnostics { + codes = append(codes, diagnostic.code) + if got := NodeDiagnostic(fmt.Errorf("private probe detail: %w", diagnostic.err)); got != diagnostic.code { + t.Errorf("wrapped readiness cause = %q, want %q", got, diagnostic.code) + } + if got := NormalizeNodeDiagnostic(diagnostic.code); got != diagnostic.code { + t.Errorf("normalized readiness cause = %q, want %q", got, diagnostic.code) + } + } + slices.Sort(codes) + slices.Sort(fixture) + if !slices.Equal(codes, fixture) { + t.Fatalf("node diagnostic fixture differs from the authored Go rule: got %v, want %v", fixture, codes) + } + if NodeDiagnostic(nil) != "" || NormalizeNodeDiagnostic("") != "" { + t.Fatal("ready state must have no diagnostic") + } + if NodeDiagnostic(errors.New("private probe detail")) != NodeProviderUnavailable || NormalizeNodeDiagnostic("future_code") != NodeProviderUnavailable { + t.Fatal("unknown causes must remain provider_unavailable") + } +} diff --git a/services/core/internal/sandbox/testdata/node-diagnostics.json b/services/core/internal/sandbox/testdata/node-diagnostics.json new file mode 100644 index 000000000..109529c5f --- /dev/null +++ b/services/core/internal/sandbox/testdata/node-diagnostics.json @@ -0,0 +1,10 @@ +[ + "provider_unavailable", + "docker_unavailable", + "docker_limits_unsupported", + "runtime_download_failed", + "runtime_image_unavailable", + "kvm_unavailable", + "microsandbox_artifacts_unavailable", + "capacity_insufficient" +] diff --git a/services/core/internal/store/node_diagnostic_contract_test.go b/services/core/internal/store/node_diagnostic_contract_test.go new file mode 100644 index 000000000..610be6748 --- /dev/null +++ b/services/core/internal/store/node_diagnostic_contract_test.go @@ -0,0 +1,55 @@ +package store + +import ( + "encoding/json" + "os" + "reflect" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestNodeDiagnosticSchemaContract(t *testing.T) { + raw, err := os.ReadFile("../sandbox/testdata/node-diagnostics.json") + if err != nil { + t.Fatal(err) + } + var codes []string + if err := json.Unmarshal(raw, &codes); err != nil { + t.Fatal(err) + } + slices.Sort(codes) + check := func(name string, values []string) { + t.Helper() + slices.Sort(values) + if !slices.Equal(values, codes) { + t.Errorf("%s diagnostic enum = %v, want shared fixture %v", name, values, codes) + } + } + for _, model := range []reflect.Type{reflect.TypeFor[RuntimeNodeHealth](), reflect.TypeFor[SandboxNodeRollout]()} { + field, ok := model.FieldByName("Diagnostic") + if !ok { + t.Fatalf("%s has no Diagnostic field", model.Name()) + } + check(model.Name(), strings.Split(field.Tag.Get("enums"), ",")) + } + raw, err = os.ReadFile("../../../../contracts/agents-api/core.openapi.yaml") + if err != nil { + t.Fatal(err) + } + var document struct { + Definitions map[string]struct { + Properties map[string]struct { + Enum []string `yaml:"enum"` + } `yaml:"properties"` + } `yaml:"definitions"` + } + if err := yaml.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + for _, name := range []string{"store.RuntimeNode", "store.RuntimeNodeDetail", "store.SandboxNodeRollout"} { + check(name, document.Definitions[name].Properties["diagnostic"].Enum) + } +}