diff --git a/Makefile b/Makefile index 51c8022a8..d1a2bbeef 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ openapi: output=$$(mktemp -d "$$root/core-openapi.XXXXXX"); trap 'rm -rf "$$output"' EXIT; \ python3 scripts/generate-public-api.py $(OPENAPI_FLAGS) --swag-roots "$$output/roots.go"; \ $(SWAG) init \ - -g cmd/server/main.go --dir "./services/core,./contracts/agents-api/v1,$$output" \ + -g cmd/server/main.go --dir "./services/core,./contracts/agents-api/v1,./internal/modelprovider,$$output" \ --output "$$output" \ --outputTypes yaml --parseInternal; \ python3 scripts/patch-agents-openapi.py "$$output/swagger.yaml"; \ diff --git a/apps/web/src/features/fleet/DiagnosticTip.tsx b/apps/web/src/features/fleet/DiagnosticTip.tsx index 6151e8386..cbb6a909e 100644 --- a/apps/web/src/features/fleet/DiagnosticTip.tsx +++ b/apps/web/src/features/fleet/DiagnosticTip.tsx @@ -6,7 +6,7 @@ import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; /** The specific reason behind a node's status, in the help tip beside it: what is wrong, then how to fix it. */ export function DiagnosticTip({ code }: { code: string }) { const { i18n } = useTranslation(); - const message = sandboxDiagnosticMessage(code, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); + const message = sandboxDiagnosticMessage(code, i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"); if (!message) return null; return ( diff --git a/apps/web/src/features/sandbox/NodeDetail.tsx b/apps/web/src/features/sandbox/NodeDetail.tsx index 6425e9766..ee1043cbe 100644 --- a/apps/web/src/features/sandbox/NodeDetail.tsx +++ b/apps/web/src/features/sandbox/NodeDetail.tsx @@ -19,7 +19,7 @@ function nodeDiagnostic(node: SandboxNode): string { function Diagnostic({ value }: { value: string }) { const { i18n } = useTranslation("sandbox"); - const message = sandboxDiagnosticMessage(value, i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"); + const message = sandboxDiagnosticMessage(value, i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"); if (!message) return <>{MISSING}; return {message.label}{message.advice}; } @@ -49,7 +49,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage; - const shortLocale = locale?.startsWith("zh") ? "zh" : "en"; + const shortLocale = locale?.startsWith("zh") ? "zh-CN" : "en"; const now = Math.floor(Date.now() / 1000); const own = allocations.filter((allocation) => allocation.node_id === node.id); const reporting = !stale && node.online; diff --git a/apps/web/src/features/sandbox/NodeEditDialog.tsx b/apps/web/src/features/sandbox/NodeEditDialog.tsx index 0cfe46ec8..8740159f6 100644 --- a/apps/web/src/features/sandbox/NodeEditDialog.tsx +++ b/apps/web/src/features/sandbox/NodeEditDialog.tsx @@ -30,7 +30,7 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved onSaved: () => void; }) { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { t: tCommon } = useTranslation("common"); const id = useId(); const [name, setName] = useState(node?.name ?? ""); @@ -61,7 +61,7 @@ export function NodeEditDialog({ client, node, size, suspends, onClose, onSaved t("Host: {{host}}.", { host: measure(hostCpus, hostMemory) }), ...(size ? [t("Each sandbox: {{size}}.", { size: measure(size.cpus, size.memory_mib * 2 ** 20) })] : []), ...(fit !== null && fit > 0 ? [t("Suggested: at most {{count}} at once.", { count: fit })] : []), - ].join(locale === "zh" ? "" : " ") : null; + ].join(locale === "zh-CN" ? "" : " ") : null; async function save() { if (!ready || !node) return; diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 8247faea1..86d0dbe2b 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -69,7 +69,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, onRefresh: () => Promise; }) { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { t: tCommon } = useTranslation("common"); const id = useId(); const [active, setActive] = useState(DEFAULT_ACTIVE); diff --git a/apps/web/src/features/sandbox/NodeList.tsx b/apps/web/src/features/sandbox/NodeList.tsx index 055c9b700..d24f73383 100644 --- a/apps/web/src/features/sandbox/NodeList.tsx +++ b/apps/web/src/features/sandbox/NodeList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { NameCell, RowActions } from "../../components/list-ui"; import { epochSeconds, formatDateTime, formatRelative } from "../../lib/format"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; import { nodeHealth, suspendedSandboxes } from "../fleet/fleet-model"; @@ -36,7 +36,7 @@ export function nodeState(node: SandboxNode, allocations: readonly SandboxAlloca } const stateTone: Record = { unconfirmed: "neutral", old_address: "warning", offline: "danger", degraded: "warning", attention: "warning", available: "ok" }; -const stateLabel: Record = { +const stateLabel: Record> = { unconfirmed: "Status unconfirmed", old_address: "Old address", offline: "Offline", diff --git a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx index f7a074c2a..8a12cf4a6 100644 --- a/apps/web/src/features/sandbox/NodeRolloutStatus.tsx +++ b/apps/web/src/features/sandbox/NodeRolloutStatus.tsx @@ -1,10 +1,10 @@ import type { SandboxNode, SandboxNodeRollout } from "@oac/agents-client"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { DiagnosticTip } from "../fleet/DiagnosticTip"; -const labels: Record = { ready: "Ready for target", preparing: "Preparing target", failed: "Preparation failed", update_required: "Node software incompatible", unknown: "Target readiness unknown" }; +const labels: Record> = { ready: "Ready for target", preparing: "Preparing target", failed: "Preparation failed", update_required: "Node software incompatible", unknown: "Target readiness unknown" }; const tones: Record = { ready: "ok", preparing: "neutral", failed: "warning", update_required: "warning", unknown: "neutral" }; /** A serving pin is historical ownership, not proof of a live connection or capacity. */ diff --git a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx index 6b071309a..b92c4b3d5 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentPage.tsx @@ -34,7 +34,7 @@ function DeploymentHeader({ actions }: { actions?: ReactNode }) { /** System's secondary page is the sole owner of deployment configuration controls. */ export function SandboxDeploymentPage() { const { i18n } = useTranslation("sandbox"); - return
+ return
; } @@ -42,7 +42,7 @@ export function SandboxDeploymentPage() { function DeploymentConfiguration() { const { t, i18n } = useTranslation("sandbox"); const { t: tNavigation } = useTranslation("sandboxNavigation"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { navigate } = useConsoleNavigation(); const queryClient = useQueryClient(); const { deploymentQuery, snapshot, installation, loading, busy, setupNeedsRefresh, confirmed, fresh, refresh, configurationKey } = useSandboxPageState(); diff --git a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx index 94e585299..4b581ace3 100644 --- a/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx +++ b/apps/web/src/features/sandbox/SandboxDeploymentSettings.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Modal } from "../../components/Modal"; import { HelpTip, RefreshButton } from "../../components/console-ui"; import { formatBytes, formatPeriod, MISSING } from "../../lib/format"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { sandboxProviderLabel } from "../../lib/sandbox-labels"; import { sandboxSize, templateBuildSize, templateBuildStatus } from "./deployment-specification"; import { SandboxRolloutSummary } from "./SandboxRolloutSummary"; @@ -12,7 +12,7 @@ import { SandboxResetControls } from "./SandboxResetControls"; import { SandboxSetupWizard } from "./SandboxSetupWizard"; const MIB = 2 ** 20; -const buildStatusLabel: Record, MessageKey> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" }; +const buildStatusLabel: Record, ParseKeys<"sandbox">> = { ready: "Ready", notReady: "Not ready", unknown: "Unknown state" }; export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset, onCancelReset, onUpdate, writeFailure, onRefresh, refreshing, pending }: { deployment: SandboxDeployment; @@ -28,7 +28,7 @@ export function SandboxDeploymentSettings({ deployment, disabled, fresh, onReset }) { const { t, i18n } = useTranslation("sandbox"); const { t: tNavigation } = useTranslation("sandboxNavigation"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const [changing, setChanging] = useState(false); const [editKey, setEditKey] = useState(0); const spec = deployment.specification; diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx index ace738101..82596e9cc 100644 --- a/apps/web/src/features/sandbox/SandboxManagerView.tsx +++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx @@ -25,7 +25,7 @@ import "./SandboxManagerView.css"; /** Nodes owns node enrollment, the list and individual node management. */ export function SandboxManagerView() { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { data: config, isError, isFetching, refetch } = useQuery(sandboxConsoleConfigQuery); return
{config ? : <> @@ -53,7 +53,7 @@ function NodesPageHeader({ title, count, back, actions, headingRef }: { title?: function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig }) { const { t, i18n } = useTranslation("sandbox"); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const { params, navigate, back: goBack } = useConsoleNavigation(); const client = sandboxAdmin; const queryClient = useQueryClient(); diff --git a/apps/web/src/features/sandbox/SandboxResetControls.tsx b/apps/web/src/features/sandbox/SandboxResetControls.tsx index d269eff98..c384c9299 100644 --- a/apps/web/src/features/sandbox/SandboxResetControls.tsx +++ b/apps/web/src/features/sandbox/SandboxResetControls.tsx @@ -21,7 +21,7 @@ export function SandboxResetControls({ deployment, disabled, stale, onStart, onC const id = useId(); const reset = deployment.reset; const [dialog, setDialog] = useState<{ action: Action; generation: number; requestedAt: string | null } | null>(null); - const [clear, setClear] = useState<"auto" | "force">("auto"); + const [clear, setClear] = useState("auto"); const [deadline, setDeadline] = useState("3600"); const [submitting, setSubmitting] = useState(false); const changed = dialog !== null && (dialog.generation !== deployment.generation || dialog.requestedAt !== (reset?.requested_at ?? null)); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index 7f10b8ca6..4f7761a43 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -13,7 +13,7 @@ import { useConsoleNavigation } from "../../lib/console-navigation"; import { coreFieldError } from "../../lib/core-error"; import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { sandboxConfigurationRejection, sandboxProviderLabel } from "../../lib/sandbox-labels"; import { defaultSandboxResources, distributionRuntime, isRuntimeRelease, isRuntimeReleaseField, RUNTIME_RELEASE_FIELDS, savedSpecification, validSandboxResources } from "./deployment-specification"; import { e2bKeyReady, e2bUpdateSelection } from "./sandbox-update"; @@ -81,7 +81,7 @@ function presets(provider: SandboxProvider): Record | return { small: scale(0.5), standard, large: scale(2) }; } -const releaseLabels: Record = { +const releaseLabels: Record> = { source_commit: "Source commit", image_id: "Image ID", image_manifest_digest: "Image manifest digest", @@ -125,7 +125,7 @@ export function SandboxSetupWizard({ coreUrl, expectedGeneration, current, disab const { t, i18n } = useTranslation("sandbox"); const { t: tCommon } = useTranslation("common"); const id = useId(); - const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; + const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh-CN" : "en"; const currentMode: Where | null = current ? deploymentContract.providers[current.provider].mode : null; const [step, setStep] = useState(editing ? currentMode === "direct" ? "e2b" : "size" : "where"); const [where, setWhere] = useState(currentMode); @@ -542,7 +542,7 @@ function NumberField({ id, label, value, onChange, error }: { error?: string | n ); } -function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?: () => void; nextDisabled?: boolean; t: (key: MessageKey) => string }) { +function Nav({ onBack, onNext, nextDisabled, t }: { onBack: () => void; onNext?: () => void; nextDisabled?: boolean; t: (key: ParseKeys<"sandbox">) => string }) { return (
diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts index 33b2e3ff5..b68c450ae 100644 --- a/apps/web/src/features/sandbox/node-enrollment.ts +++ b/apps/web/src/features/sandbox/node-enrollment.ts @@ -1,6 +1,6 @@ import type { SandboxEnrollment, SandboxNode } from "@oac/agents-client"; -import type { MessageKey } from "../../lib/locale-strings"; +import type { ParseKeys } from "i18next"; import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; /** @@ -11,7 +11,7 @@ import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic"; export const NODE_READY_WAIT_MS = 60_000; export interface HostPrerequisite { - label: MessageKey; + label: ParseKeys<"sandbox">; } /** diff --git a/apps/web/src/i18n/README.md b/apps/web/src/i18n/README.md index 4fba2e457..51cda8d31 100644 --- a/apps/web/src/i18n/README.md +++ b/apps/web/src/i18n/README.md @@ -5,7 +5,7 @@ The console uses `i18next` and `react-i18next`. English is the fallback language Translations are split by namespace, registered in `resources.ts`: - `locales/en/*.ts` and `locales/zh-CN/*.ts` hold one file per feature namespace: `common` (reusable actions and labels, with the Core error messages of `core-errors.ts` nested inside it), `navigation` (the shell), `pages`, `agents`, `templates`, `vaults`, `files`, `skills`, `keys`, `sessions`, `diagnostics`, `dashboard`, `overview`, `metrics`, `system`, `sandbox-navigation` (registered as `sandboxNavigation`) and `onboarding`. -- The `sandbox` and `firstRun` namespaces come from `src/lib/locale-strings.ts` and `src/lib/console-auth-strings.ts`. Their keys are the English text and their values the Chinese translation. Sandbox status and node diagnostic formatting in `src/lib/sandbox-labels.ts` and `src/lib/sandbox-diagnostic.ts` reads the same strings. +- `sandbox` and `firstRun` use English text as keys in `locales/zh-CN/sandbox.ts` and `locales/zh-CN/first-run.ts`; `resources.ts` projects those keys into the English resources. Keep these keys unchanged when moving copy. Add a namespace when a feature grows beyond page-level labels; do not grow one application-wide translation object. @@ -13,8 +13,9 @@ When adding or changing copy: 1. Add the English key and the `zh-CN` translation in matching namespace files. 2. Consume the key with `useTranslation(namespace)` in React components. -3. Use interpolation for dynamic values instead of concatenating translated text. -4. Keep API values, identifiers, paths, commands and user-provided content out of translation resources. -5. Run the Web tests. The resource parity test rejects keys missing from either language. +3. Outside React, use `i18n.getFixedT(language, namespace)` with `SupportedLanguage` for an explicit language without changing the console language; use i18next’s `ParseKeys` for typed key maps. +4. Use interpolation for dynamic values instead of concatenating translated text. +5. Keep API values, identifiers, paths, commands and user-provided content out of translation resources. +6. Run the Web tests. The resource parity test rejects keys missing from either language. The initial language follows the browser preference (`zh*` selects `zh-CN`) unless the user has chosen a language in the console menu. The choice is stored in local storage; the console still works when browser storage is unavailable. diff --git a/apps/web/src/lib/console-auth-strings.ts b/apps/web/src/i18n/locales/zh-CN/first-run.ts similarity index 97% rename from apps/web/src/lib/console-auth-strings.ts rename to apps/web/src/i18n/locales/zh-CN/first-run.ts index c6490daef..eacaec837 100644 --- a/apps/web/src/lib/console-auth-strings.ts +++ b/apps/web/src/i18n/locales/zh-CN/first-run.ts @@ -1,4 +1,4 @@ -export const consoleAuthChinese = { +export const firstRun = { "Try again": "重试", "Sign in to OpenAgentCore": "登录 OpenAgentCore", "Core key": "Core Key", diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/i18n/locales/zh-CN/sandbox.ts similarity index 99% rename from apps/web/src/lib/locale-strings.ts rename to apps/web/src/i18n/locales/zh-CN/sandbox.ts index cf6eaa7ff..fab73e48c 100644 --- a/apps/web/src/lib/locale-strings.ts +++ b/apps/web/src/i18n/locales/zh-CN/sandbox.ts @@ -1,5 +1,5 @@ -import { consoleAuthChinese } from "./console-auth-strings"; -export const chinese = { +import { firstRun } from "./first-run"; +export const sandbox = { "E2B provider": "E2B 服务商", "Other E2B-compatible provider": "其他兼容 E2B 的服务商", "Loading templates…": "正在读取模板…", @@ -13,7 +13,7 @@ export const chinese = { "Select a ready build": "选择 ready 构建", "Core validates the exact ready build again when you save.": "保存时 Core 会再次校验所选的 ready 构建。", "The selected provider supplies a default. You can edit it for a compatible endpoint.": "服务商已带出默认地址,也可以修改为兼容接口。", - ...consoleAuthChinese, + ...firstRun, "Hosted Sessions will be archived permanently. Deployment configuration and node registrations will be cleared. Unsaved workspace contents may be lost.": "托管 Session 将归档且无法恢复,部署配置与节点注册将清除。未保存的工作区内容可能丢失。", "What reset affects": "重置影响范围", "How automatic reset works": "自动清理规则", @@ -421,4 +421,3 @@ export const chinese = { "{{name}} is still bound to an old Core address. Remove it and add it again.": "{{name}} 仍绑定在旧的 Core 地址上,需要移除后重新添加。", "{{count}} nodes are still bound to an old Core address: {{names}}. Remove them and add them again.": "有 {{count}} 个节点仍绑定在旧的 Core 地址上:{{names}}。需要移除后重新添加。", } as const; -export type MessageKey = keyof typeof chinese; diff --git a/apps/web/src/i18n/resources.ts b/apps/web/src/i18n/resources.ts index d4774c8b1..2d970c6e5 100644 --- a/apps/web/src/i18n/resources.ts +++ b/apps/web/src/i18n/resources.ts @@ -32,17 +32,13 @@ import { skills as zhCNSkills } from "./locales/zh-CN/skills"; import { keys as zhCNKeys } from "./locales/zh-CN/keys"; import { system as zhCNSystem } from "./locales/zh-CN/system"; import { onboarding as zhCNOnboarding } from "./locales/zh-CN/onboarding"; -import { consoleAuthChinese } from "../lib/console-auth-strings"; -import { chinese as zhCNSandbox } from "../lib/locale-strings"; +import { firstRun as zhCNFirstRun } from "./locales/zh-CN/first-run"; +import { sandbox as zhCNSandbox } from "./locales/zh-CN/sandbox"; const enSandbox = Object.fromEntries( Object.keys(zhCNSandbox).map((key) => [key, key]), ) as { [K in keyof typeof zhCNSandbox]: K }; -const zhCNFirstRun = { - ...consoleAuthChinese, -} as const; - const enFirstRun = Object.fromEntries( Object.keys(zhCNFirstRun).map((key) => [key, key]), ) as { [K in keyof typeof zhCNFirstRun]: K }; diff --git a/apps/web/src/lib/locale.test.ts b/apps/web/src/lib/locale.test.ts index 1d61ce7e1..ad570bde6 100644 --- a/apps/web/src/lib/locale.test.ts +++ b/apps/web/src/lib/locale.test.ts @@ -6,29 +6,29 @@ import { sandboxDiagnosticMessage } from "./sandbox-diagnostic"; describe("sandbox localization", () => { it("localizes every persisted allocation and compute state without exposing unknown values", () => { for (const state of ["creating", "running", "cleanup_pending", "released", "disabled", "quiescing", "suspending", "suspended", "restoring", "waking"]) { - expect(sandboxStateLabel(state, "zh")).toMatch(/[\u4e00-\u9fff]/); - expect(sandboxStateLabel(state, "zh")).not.toBe("未知状态"); + expect(sandboxStateLabel(state, "zh-CN")).toMatch(/[\u4e00-\u9fff]/); + expect(sandboxStateLabel(state, "zh-CN")).not.toBe("未知状态"); } - expect(sandboxStateLabel("internal-value", "zh")).toBe("未知状态"); + expect(sandboxStateLabel("internal-value", "zh-CN")).toBe("未知状态"); }); it("localizes all diagnostic labels and advice", () => { for (const code of ["node_unavailable", "resource_missing", "compute_unconfirmed", "ownership_mismatch", "provider_unavailable", "host_unsupported", "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", "capacity_insufficient", "unknown"]) { - const message = sandboxDiagnosticMessage(code, "zh"); + const message = sandboxDiagnosticMessage(code, "zh-CN"); expect(message?.label).toMatch(/[\u4e00-\u9fff]/); expect(message?.advice).toMatch(/[\u4e00-\u9fff]/); } - expect(sandboxDiagnosticMessage("", "zh")).toBeNull(); + expect(sandboxDiagnosticMessage("", "zh-CN")).toBeNull(); }); it("shows Core's reason for a refusal and keeps other failures to the console's words", () => { // A code with one exact meaning keeps the console's localized words. - expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); + expect(sandboxRequestError(new AgentCoreError("Node node-edge still has allocations.", 409, "runtime_node_in_use"), "zh-CN")).toBe("节点仍有活跃分配或保留资源。请先清理资源分配、快照、预留资源和待清理项,再移除节点。"); // A refusal is Core's to explain: one code, such as a 409 conflict, covers several reasons. - expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh")).toBe("This console is read-only."); - expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh")).toBe("The session is busy."); + expect(sandboxRequestError(new AgentCoreError("This console is read-only.", 403), "zh-CN")).toBe("This console is read-only."); + expect(sandboxRequestError(new AgentCoreError("The session is busy.", 409, "conflict_error"), "zh-CN")).toBe("The session is busy."); // A sandbox refusal whose reason the client withheld is named without it. - expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh")).toBe("Core 拒绝了这个沙箱配置。"); - expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh")).not.toContain("raw secret"); - expect(sandboxRequestError(new Error("raw secret"), "zh")).not.toContain("raw secret"); + expect(sandboxRequestError(new AgentCoreError("withheld", 400, "sandbox_configuration_unconfirmed"), "zh-CN")).toBe("Core 拒绝了这个沙箱配置。"); + expect(sandboxRequestError(new AgentCoreError("raw secret", 502), "zh-CN")).not.toContain("raw secret"); + expect(sandboxRequestError(new Error("raw secret"), "zh-CN")).not.toContain("raw secret"); }); }); diff --git a/apps/web/src/lib/locale.ts b/apps/web/src/lib/locale.ts deleted file mode 100644 index 9f02335c9..000000000 --- a/apps/web/src/lib/locale.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { chinese, type MessageKey } from "./locale-strings"; -export type Locale = "en" | "zh"; -export function translate(locale: Locale, key: MessageKey): string { - return locale === "zh" ? chinese[key] : key; -} diff --git a/apps/web/src/lib/sandbox-diagnostic.test.ts b/apps/web/src/lib/sandbox-diagnostic.test.ts index 5c6c5a0c6..65a830b86 100644 --- a/apps/web/src/lib/sandbox-diagnostic.test.ts +++ b/apps/web/src/lib/sandbox-diagnostic.test.ts @@ -39,11 +39,11 @@ describe("sandbox diagnostics", () => { label: "Runtime download failed", advice: "Runtime files could not be downloaded or verified. Check the node's network access and the configured Runtime release.", }); - expect(sandboxDiagnosticMessage(diagnostic, "zh")).toEqual({ + expect(sandboxDiagnosticMessage(diagnostic, "zh-CN")).toEqual({ label: "Runtime 下载失败", advice: "Runtime 文件下载或验证失败。请检查节点网络连接及配置的 Runtime 发布版本。", }); - for (const locale of ["en", "zh"] as const) { + for (const locale of ["en", "zh-CN"] as const) { expect(sandboxDiagnosticMessage(diagnostic, locale)).not.toEqual(sandboxDiagnosticMessage("runtime_image_unavailable", locale)); } }); @@ -53,7 +53,7 @@ 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"); + const chinese = sandboxDiagnosticMessage(diagnostic, "zh-CN"); 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); diff --git a/apps/web/src/lib/sandbox-diagnostic.ts b/apps/web/src/lib/sandbox-diagnostic.ts index 37d350819..aaf2ecbd0 100644 --- a/apps/web/src/lib/sandbox-diagnostic.ts +++ b/apps/web/src/lib/sandbox-diagnostic.ts @@ -1,9 +1,9 @@ import { normalizeSandboxNodeDiagnostic, type SandboxNode } from "@oac/agents-client"; -import { translate, type Locale } from "./locale"; -import type { MessageKey } from "./locale-strings"; +import i18n, { type SupportedLanguage } from "../i18n"; +import type { ParseKeys } from "i18next"; export interface SandboxDiagnosticMessage { label: string; advice: string } -const diagnostics: Record = { +const diagnostics: Record; advice: ParseKeys<"sandbox"> }> = { node_unavailable: { label: "Node disconnected", advice: "Reconnect the assigned node, then refresh. Existing resources stay assigned to this node; Core does not move the Session automatically.", @@ -57,11 +57,12 @@ export function nodeProviderDiagnostic(node: Pick { it("uses fixed bilingual E2B errors without reflecting provider text or secrets", () => { for (const [code, status] of [["sandbox_credential_ownership", 409], ["sandbox_credential_invalid", 400], ["sandbox_configuration_invalid", 400], ["sandbox_verification_unconfirmed", 503]] as const) { const error = new AgentCoreError("secret-provider-response", status, code); - for (const locale of ["en", "zh"] as const) { + for (const locale of ["en", "zh-CN"] as const) { expect(sandboxRequestError(error, locale)).not.toContain("secret-provider-response"); if (status < 500) expect(sandboxConfigurationRejection(error, locale)).toBe(sandboxRequestError(error, locale)); else expect(sandboxConfigurationRejection(error, locale)).toBeNull(); @@ -31,16 +31,16 @@ describe("reset conflict recovery", () => { it("explains reset-required configuration changes without assuming a different backend or team", () => { const error = new AgentCoreError("untrusted-provider-detail", 409, "sandbox_reset_required"); expect(sandboxConfigurationRejection(error, "en")).toBe("Reset the sandbox deployment before changing this configuration."); - expect(sandboxRequestError(error, "zh")).toBe("请先重置沙箱部署,再更改此配置。"); + expect(sandboxRequestError(error, "zh-CN")).toBe("请先重置沙箱部署,再更改此配置。"); }); it("gives bilingual safe recovery for known state conflicts without reflecting a server payload", () => { for (const code of ["sandbox_generation_stale", "sandbox_reset_required", "sandbox_reset_in_progress", "sandbox_not_configured", "sandbox_in_use"]) { const error = new AgentCoreError("untrusted-secret-like-payload", 409, code); - for (const locale of ["en", "zh"] as const) expect(sandboxRequestError(error, locale)).not.toContain("untrusted-secret-like-payload"); + for (const locale of ["en", "zh-CN"] as const) expect(sandboxRequestError(error, locale)).not.toContain("untrusted-secret-like-payload"); expect(sandboxWriteUncertain(error)).toBe(false); } expect(sandboxRequestError(new AgentCoreError("stale", 409, "sandbox_generation_stale"), "en")).toContain("Refresh and review"); - expect(sandboxRequestError(new AgentCoreError("reset", 409, "sandbox_reset_in_progress"), "zh")).toContain("正在重置"); + expect(sandboxRequestError(new AgentCoreError("reset", 409, "sandbox_reset_in_progress"), "zh-CN")).toContain("正在重置"); }); }); diff --git a/apps/web/src/lib/sandbox-labels.ts b/apps/web/src/lib/sandbox-labels.ts index 289793d89..b4eb300dd 100644 --- a/apps/web/src/lib/sandbox-labels.ts +++ b/apps/web/src/lib/sandbox-labels.ts @@ -1,22 +1,21 @@ -import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxNode, type SandboxProvider } from "@oac/agents-client"; -import i18n from "../i18n"; +import { AgentCoreError, sandboxConfigurationUnconfirmed, type SandboxProvider } from "@oac/agents-client"; +import i18n, { type SupportedLanguage } from "../i18n"; import { knownCoreError } from "./core-error"; -import { translate, type Locale } from "./locale"; -import type { MessageKey } from "./locale-strings"; +import type { ParseKeys } from "i18next"; -const states: Record = { +const states: Record> = { reserved: "Reserved state", creating: "Creating", active: "Active", releasing: "Releasing", released: "Released", failed: "Failed", pending: "Pending", cleanup_pending: "Cleanup pending", disabled: "Disabled", waking: "Waking", running: "Running", quiescing: "Quiescing", suspending: "Suspending", suspended: "Suspended", restoring: "Restoring", stopped: "Stopped", }; -export function sandboxStateLabel(state: string, locale: Locale): string { - return translate(locale, Object.hasOwn(states, state) ? states[state]! : "Unknown state"); +export function sandboxStateLabel(state: string, locale: SupportedLanguage): string { + return i18n.getFixedT(locale, "sandbox")(Object.hasOwn(states, state) ? states[state]! : "Unknown state"); } /** Localized catalog errors first; unknown refusals retain Core’s message. */ -export function sandboxRequestError(error: unknown, locale: Locale): string { - const known = knownCoreError(error, i18n.getFixedT(locale === "zh" ? "zh-CN" : "en", "common"), "bytes"); +export function sandboxRequestError(error: unknown, locale: SupportedLanguage): string { + const known = knownCoreError(error, i18n.getFixedT(locale, "common"), "bytes"); if (known) return known; - let key: MessageKey = "The sandbox request failed. Refresh to check the current state before trying again."; + let key: ParseKeys<"sandbox"> = "The sandbox request failed. Refresh to check the current state before trying again."; if (error instanceof AgentCoreError) { const refused = !sandboxWriteUncertain(error); if (error.code === sandboxConfigurationUnconfirmed) { if (refused) key = "Core rejected the sandbox configuration."; else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } @@ -25,7 +24,7 @@ export function sandboxRequestError(error: unknown, locale: Locale): string { key = "The sandbox request was rejected. Refresh to check the current state."; } else if (error.status >= 500) key = "The sandbox service is unavailable. Refresh to check the current state."; } else if (error instanceof Error && error.message === "removal_unconfirmed") key = "Core did not confirm node removal. Refresh to check its state."; - return translate(locale, key); + return i18n.getFixedT(locale, "sandbox")(key); } /** @@ -45,20 +44,16 @@ export function sandboxWriteUncertain(error: unknown): boolean { * such as a loopback public_url; null for any other failure. Nothing * was saved, so the administrator corrects the cause and saves again. */ -export function sandboxConfigurationRejection(error: unknown, locale: Locale = "en"): string | null { +export function sandboxConfigurationRejection(error: unknown, locale: SupportedLanguage = "en"): string | null { if (error instanceof AgentCoreError && !sandboxWriteUncertain(error) && error.code) { - const known = knownCoreError(error, i18n.getFixedT(locale === "zh" ? "zh-CN" : "en", "common")); + const known = knownCoreError(error, i18n.getFixedT(locale, "common")); if (known) return known; } return error instanceof AgentCoreError && error.status === 409 && error.code === "sandbox_configuration_error" && error.message ? error.message : null; } -export function sandboxNodeStatus(node: SandboxNode, stale: boolean, locale: Locale): string { - return translate(locale, stale ? "Status unconfirmed" : !node.online ? "Offline" : node.provider_ready ? "Available" : "Unavailable"); -} - -export function sandboxProviderLabel(provider: SandboxProvider | "", locale: Locale): string { +export function sandboxProviderLabel(provider: SandboxProvider | "", locale: SupportedLanguage): string { if (provider === "docker") return "Docker"; if (provider === "microsandbox") return "microsandbox"; - return translate(locale, provider === "e2b" ? "E2B cloud" : "Unknown state"); + return i18n.getFixedT(locale, "sandbox")(provider === "e2b" ? "E2B cloud" : "Unknown state"); } diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 6c235c3a4..cfb5e94a0 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -383,6 +383,16 @@ definitions: - failed_at - params type: object + api.DiagnosticSource: + enum: + - turn + - environment + - environment_input + type: string + x-enum-varnames: + - DiagnosticTurn + - DiagnosticEnvironment + - DiagnosticEnvironmentInput api.EnvironmentExecutorCredentialRequest: properties: key_id: @@ -404,16 +414,22 @@ definitions: type: string x-nullable: true status: - enum: - - never_enrolled - - connected - - disconnected - type: string + $ref: '#/definitions/api.ExecutorConnectionStatus' required: - bound_key_id - enrolled_at - status type: object + api.ExecutorConnectionStatus: + enum: + - never_enrolled + - connected + - disconnected + type: string + x-enum-varnames: + - ExecutorNeverEnrolled + - ExecutorConnected + - ExecutorDisconnected api.ExecutorCredentialList: properties: connection: @@ -441,17 +457,8 @@ definitions: type: string x-nullable: true last_error_code: - enum: - - authentication_error - - connection_failed - - rate_limit_exceeded - - usage_limit_exceeded - - server_overloaded - - server_error - - resource_not_found - - request_timeout - - invalid_request - type: string + allOf: + - $ref: '#/definitions/modelconfiguration.ProviderErrorCode' x-nullable: true last_used_at: format: date-time @@ -525,6 +532,14 @@ definitions: required: - settings type: object + api.InstallationService: + enum: + - core + - web + type: string + x-enum-varnames: + - InstallationCore + - InstallationWeb api.InstallationSetting: properties: changeable: @@ -541,10 +556,7 @@ definitions: restarts: description: Services that restart when the setting changes. items: - enum: - - core - - web - type: string + $ref: '#/definitions/api.InstallationService' type: array sensitive: type: boolean @@ -743,11 +755,7 @@ definitions: params: type: object source: - enum: - - turn - - environment - - environment_input - type: string + $ref: '#/definitions/api.DiagnosticSource' turn_id: type: string required: @@ -924,12 +932,7 @@ definitions: type: integer x-nullable: true status: - enum: - - ok - - failing - - stopped - - unknown - type: string + $ref: '#/definitions/coremetrics.JobStatus' required: - failed - id @@ -937,6 +940,18 @@ definitions: - processed - status type: object + coremetrics.JobStatus: + enum: + - ok + - failing + - stopped + - unknown + type: string + x-enum-varnames: + - JobOk + - JobFailing + - JobStopped + - JobUnknown coremetrics.Latency: properties: p50: @@ -1038,16 +1053,21 @@ definitions: type: string x-nullable: true status: - enum: - - running - - degraded - type: string + $ref: '#/definitions/coremetrics.ServiceStatus' required: - execution_owner - revision - started_at - status type: object + coremetrics.ServiceStatus: + enum: + - running + - degraded + type: string + x-enum-varnames: + - ServiceRunning + - ServiceDegraded coremetrics.View: properties: database: @@ -1147,15 +1167,9 @@ definitions: created_at: type: string diagnostic: + allOf: + - $ref: '#/definitions/sandbox.NodeDiagnosticCode' description: Fixed reason for the last reported unreadiness; absent while the provider is ready. Clients treat an unknown value as provider_unavailable. - enum: - - provider_unavailable - - host_unsupported - - artifacts_unavailable - - runtime_download_failed - - runtime_image_unavailable - - capacity_insufficient - type: string enrollment_id: description: The enrollment_id of the command that registered this node (POST /core/v1/sandbox/enrollment-tokens); null for nodes enrolled before Core recorded it. type: string @@ -1282,15 +1296,9 @@ definitions: created_at: type: string diagnostic: + allOf: + - $ref: '#/definitions/sandbox.NodeDiagnosticCode' description: Fixed reason for the last reported unreadiness; absent while the provider is ready. Clients treat an unknown value as provider_unavailable. - enum: - - provider_unavailable - - host_unsupported - - artifacts_unavailable - - runtime_download_failed - - runtime_image_unavailable - - capacity_insufficient - type: string enrollment_id: description: The enrollment_id of the command that registered this node (POST /core/v1/sandbox/enrollment-tokens); null for nodes enrolled before Core recorded it. type: string @@ -1382,31 +1390,33 @@ definitions: deployment.NodeRollout: properties: diagnostic: - enum: - - provider_unavailable - - host_unsupported - - artifacts_unavailable - - runtime_download_failed - - runtime_image_unavailable - - capacity_insufficient - type: string + $ref: '#/definitions/sandbox.NodeDiagnosticCode' ready_generation: description: Durable serving-generation pin; online and provider_ready still gate placement. type: integer x-nullable: true state: + allOf: + - $ref: '#/definitions/deployment.NodeRolloutState' description: Target preparation, independent of an old pin's serving readiness. - enum: - - ready - - preparing - - failed - - update_required - - unknown - type: string required: - ready_generation - state type: object + deployment.NodeRolloutState: + enum: + - ready + - preparing + - failed + - update_required + - unknown + type: string + x-enum-varnames: + - NodeRolloutReady + - NodeRolloutPreparing + - NodeRolloutFailed + - NodeRolloutUpdateRequired + - NodeRolloutUnknown deployment.NodeUpdate: properties: max_active: @@ -1423,10 +1433,7 @@ definitions: deployment.Reset: properties: clear: - enum: - - auto - - force - type: string + $ref: '#/definitions/deployment.ResetMode' deadline_at: type: string x-nullable: true @@ -1444,6 +1451,14 @@ definitions: - remaining - requested_at type: object + deployment.ResetMode: + enum: + - auto + - force + type: string + x-enum-varnames: + - ResetAuto + - ResetForce deployment.ResetOfflineNode: properties: name: @@ -1481,10 +1496,7 @@ definitions: deployment.ResetRequest: properties: clear: - enum: - - auto - - force - type: string + $ref: '#/definitions/deployment.ResetMode' deadline_seconds: maximum: 86400 minimum: 300 @@ -1515,10 +1527,7 @@ definitions: previous_generation_sandboxes: type: integer state: - enum: - - settled - - preparing - type: string + $ref: '#/definitions/deployment.RolloutState' required: - nodes - previous_generation_sandboxes @@ -1543,6 +1552,14 @@ definitions: - unknown - update_required type: object + deployment.RolloutState: + enum: + - settled + - preparing + type: string + x-enum-varnames: + - RolloutSettled + - RolloutPreparing deployment.Suspension: properties: idle_seconds: @@ -1569,11 +1586,7 @@ definitions: metadata: type: object mode: - enum: - - "" - - nodes - - direct - type: string + $ref: '#/definitions/sandbox.DeploymentMode' owner_epoch: type: integer provider: @@ -1608,6 +1621,38 @@ definitions: - rollout - suspension type: object + modelconfiguration.ProviderErrorCode: + enum: + - authentication_error + - connection_failed + - rate_limit_exceeded + - usage_limit_exceeded + - server_overloaded + - server_error + - resource_not_found + - request_timeout + - invalid_request + type: string + x-enum-varnames: + - ProviderAuthenticationError + - ProviderConnectionFailed + - ProviderRateLimitExceeded + - ProviderUsageLimitExceeded + - ProviderServerOverloaded + - ProviderServerError + - ProviderResourceNotFound + - ProviderRequestTimeout + - ProviderInvalidRequest + modelprovider.Protocol: + enum: + - anthropic + - responses + - chat_completions + type: string + x-enum-varnames: + - Anthropic + - Responses + - ChatCompletions projects.APIKey: properties: created_at: @@ -1710,6 +1755,16 @@ definitions: query: type: object type: object + sandbox.DeploymentMode: + enum: + - "" + - nodes + - direct + type: string + x-enum-varnames: + - DeploymentUnconfigured + - DeploymentNodes + - DeploymentDirect sandbox.DeploymentSpec: properties: resources: @@ -1719,6 +1774,22 @@ definitions: required: - resources type: object + sandbox.NodeDiagnosticCode: + enum: + - provider_unavailable + - host_unsupported + - artifacts_unavailable + - runtime_download_failed + - runtime_image_unavailable + - capacity_insufficient + type: string + x-enum-varnames: + - NodeProviderUnavailable + - NodeHostUnsupported + - NodeArtifactsUnavailable + - NodeRuntimeDownloadFailed + - NodeRuntimeImageUnavailable + - NodeCapacityInsufficient sandbox.Resources: properties: cpus: @@ -1811,16 +1882,22 @@ definitions: session_id: type: string state: - enum: - - active - - cleanup_pending - - released - type: string + $ref: '#/definitions/sessions.ManagedArchiveState' required: - environment_id - session_id - state type: object + sessions.ManagedArchiveState: + enum: + - active + - cleanup_pending + - released + type: string + x-enum-varnames: + - ManagedArchiveActive + - ManagedArchiveCleanupPending + - ManagedArchiveReleased v1.Agent: properties: id: @@ -1988,10 +2065,7 @@ definitions: message: type: string status: - enum: - - available - - unavailable - type: string + $ref: '#/definitions/v1.InstallationStatus' version: type: string type: object @@ -2121,12 +2195,7 @@ definitions: v1.ExecutionHarnessConfigSelection: properties: source: - enum: - - session - - agent - - deployment - - unknown - type: string + $ref: '#/definitions/v1.ExecutionSource' value: type: object required: @@ -2140,32 +2209,28 @@ definitions: - $ref: '#/definitions/v1.ModelProviderView' x-nullable: true source: - enum: - - session - - agent - - deployment - - unknown - type: string + $ref: '#/definitions/v1.ExecutionSource' status: - enum: - - available - - redacted - - unavailable - type: string + $ref: '#/definitions/v1.ExecutionProviderStatus' required: - configuration - source - status type: object + v1.ExecutionProviderStatus: + enum: + - available + - redacted + - unavailable + type: string + x-enum-varnames: + - ExecutionProviderAvailable + - ExecutionProviderRedacted + - ExecutionProviderUnavailable v1.ExecutionSelection: properties: source: - enum: - - session - - agent - - deployment - - unknown - type: string + $ref: '#/definitions/v1.ExecutionSource' value: type: string x-nullable: true @@ -2173,6 +2238,18 @@ definitions: - source - value type: object + v1.ExecutionSource: + enum: + - session + - agent + - deployment + - unknown + type: string + x-enum-varnames: + - ExecutionSourceSession + - ExecutionSourceAgent + - ExecutionSourceDeployment + - ExecutionSourceUnknown v1.InputTokenDetails: properties: cached_tokens: @@ -2180,6 +2257,14 @@ definitions: required: - cached_tokens type: object + v1.InstallationStatus: + enum: + - available + - unavailable + type: string + x-enum-varnames: + - InstallationAvailable + - InstallationUnavailable v1.Item: properties: action: @@ -2359,11 +2444,7 @@ definitions: max_output_tokens: type: integer protocol: - enum: - - anthropic - - responses - - chat_completions - type: string + $ref: '#/definitions/modelprovider.Protocol' required: - api_key - base_url @@ -2380,11 +2461,7 @@ definitions: max_output_tokens: type: integer protocol: - enum: - - anthropic - - responses - - chat_completions - type: string + $ref: '#/definitions/modelprovider.Protocol' required: - api_key_configured - base_url @@ -3750,18 +3827,28 @@ definitions: - prefix - revoked_at type: object + writeaudit.Action: + enum: + - create + - update + - delete + - send_events + - upload_file + - upload_version + - update_default_version + type: string + x-enum-varnames: + - ActionCreate + - ActionUpdate + - ActionDelete + - ActionSendEvents + - ActionUploadFile + - ActionUploadVersion + - ActionUpdateDefaultVersion writeaudit.Operation: properties: action: - enum: - - create - - update - - delete - - send_events - - upload_file - - upload_version - - update_default_version - type: string + $ref: '#/definitions/writeaudit.Action' api_key: $ref: '#/definitions/writeaudit.APIKey' created_at: @@ -3775,18 +3862,7 @@ definitions: resource_id: type: string resource_type: - enum: - - agent - - session - - environment - - environment_template - - skill - - skill_version - - file - - vault - - credential - - artifact - type: string + $ref: '#/definitions/writeaudit.ResourceType' trace_id: type: string required: @@ -3827,6 +3903,30 @@ definitions: - api_key - resource_id type: object + writeaudit.ResourceType: + enum: + - agent + - session + - environment + - environment_template + - skill + - skill_version + - file + - vault + - credential + - artifact + type: string + x-enum-varnames: + - ResourceAgent + - ResourceSession + - ResourceEnvironment + - ResourceEnvironmentTemplate + - ResourceSkill + - ResourceSkillVersion + - ResourceFile + - ResourceVault + - ResourceCredential + - ResourceArtifact info: contact: {} description: Deployment and operations routes under /core/v1 for Core Web's server and operator scripts. Every operation requires the Core key; Project API keys and machine credentials are not accepted. diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 84e391381..bdbbf26c9 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -14,7 +14,7 @@ Core targets the complete OpenAI Agents API as pinned below ([public API rule](h | [openapi.yaml](./openapi.yaml) | The official public contract with Core's `x_agents_core` extension on Agent and Session request/response objects | | [go-bindings.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/go-bindings.json) | Go names, field representations, encoding order and stored projections; it does not define official field membership, enums or constraints | -Run `make openapi` to regenerate the public Go types, the Agent request shapes, the route inventory and all three OpenAPI documents. `scripts/generate-public-api.py` reads the checked-in, checksum-verified official source without network access. It selects Agents, Vaults, Files and Skills and follows their schema references, preserving union types, nullability, required fields and constraints. The request shapes in `services/core/internal/api/official_shapes.gen.go` project `CreateAgentParams`, `UpdateAgentParams` and `SessionAgentConfigParam`; Core checks request bodies against them before it reads an Agent configuration. Core's extension types in `v1/` remain authored in Go and are added to the public schema during generation. The TypeScript client's types, enum values and field names in `packages/agents-client/src/generated/public-api.ts` are generated from the resulting public schema. The internal `/core/v1` and `/api/v1` documents come from handler annotations; the same generator projects the `/core/v1` document into `packages/agents-client/src/generated/core-api.ts`, which imports the public types it references from `public-api.ts`. In those annotations a response field Core always sends carries `binding:"required"`, a field that can be null carries `extensions:"x-nullable"`, and a closed set carries `enums:`; the tags only shape the documents, and the client rejects a response that breaks them. `make check-openapi` checks freshness and the generator; it also runs through `make check-go`. +Run `make openapi` to regenerate the public Go types, the Agent request shapes, the route inventory and all three OpenAPI documents. `scripts/generate-public-api.py` reads the checked-in, checksum-verified official source without network access. It selects Agents, Vaults, Files and Skills and follows their schema references, preserving union types, nullability, required fields and constraints. The request shapes in `services/core/internal/api/official_shapes.gen.go` project `CreateAgentParams`, `UpdateAgentParams` and `SessionAgentConfigParam`; Core checks request bodies against them before it reads an Agent configuration. Core's extension types in `v1/` remain authored in Go and are added to the public schema during generation. The TypeScript client's types, enum values and field names in `packages/agents-client/src/generated/public-api.ts` are generated from the resulting public schema. The internal `/core/v1` and `/api/v1` documents come from handler annotations; the same generator projects the `/core/v1` document into `packages/agents-client/src/generated/core-api.ts`, which imports the public types it references from `public-api.ts`. In those annotations a response field Core always sends carries `binding:"required"`, a field that can be null carries `extensions:"x-nullable"`, and a Core-owned closed set uses a named Go type with typed constants in its producing package. Swag derives one enum definition from that type, shared by every wire field. Single-value discriminators keep `enums:` tags; fields mirroring official or Runtime-owned sets retain tags checked against their owners by contract tests. These annotations only shape the documents, and the client rejects a response that breaks them. `make check-openapi` checks freshness and the generator; it also runs through `make check-go`. The public contract is the official API plus Core extensions. Standard fields are generated into `v1/official.gen.go`; `go-bindings.json` lists types consumed by Core and overrides only the Go representation or field order that existing storage or custom JSON encoding requires. Unspecified fields follow the official schema; shared shapes use one Go type. Selected discriminated unions also generate JSON serializers to retain required nullable fields for each variant. Other union serializers, Core's local limits, execution admission and state transitions remain implementation code. Contract tests verify that the public schema preserves the official definitions, extensions remain in `x_agents_core`, and all documents match registered routes. Official-client and raw HTTP tests verify behavior. Schema generation does not qualify an unimplemented feature; the gaps below still apply. Upstream upgrades update the OpenAPI and SDK pins together after comparison and compatibility tests. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 1885da9ed..43e25a570 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -17663,6 +17663,19 @@ "additionalProperties": false, "description": "Confirmation that a vault credential was deleted." }, + "modelprovider.Protocol": { + "enum": [ + "anthropic", + "responses", + "chat_completions" + ], + "type": "string", + "x-enum-varnames": [ + "Anthropic", + "Responses", + "ChatCompletions" + ] + }, "v1.AgentsCore": { "properties": { "harness": { @@ -17694,11 +17707,7 @@ "type": "string" }, "status": { - "enum": [ - "available", - "unavailable" - ], - "type": "string" + "$ref": "#/components/schemas/v1.InstallationStatus" }, "version": { "type": "string" @@ -17706,6 +17715,17 @@ }, "type": "object" }, + "v1.InstallationStatus": { + "enum": [ + "available", + "unavailable" + ], + "type": "string", + "x-enum-varnames": [ + "InstallationAvailable", + "InstallationUnavailable" + ] + }, "v1.ModelProviderInput": { "properties": { "api_key": { @@ -17721,12 +17741,7 @@ "type": "integer" }, "protocol": { - "enum": [ - "anthropic", - "responses", - "chat_completions" - ], - "type": "string" + "$ref": "#/components/schemas/modelprovider.Protocol" } }, "required": [ @@ -17751,12 +17766,7 @@ "type": "integer" }, "protocol": { - "enum": [ - "anthropic", - "responses", - "chat_completions" - ], - "type": "string" + "$ref": "#/components/schemas/modelprovider.Protocol" } }, "required": [ diff --git a/contracts/agents-api/v1/installation.go b/contracts/agents-api/v1/installation.go index 49b7f86d0..b00e3ca40 100644 --- a/contracts/agents-api/v1/installation.go +++ b/contracts/agents-api/v1/installation.go @@ -1,5 +1,12 @@ package v1 +type InstallationStatus string + +const ( + InstallationAvailable InstallationStatus = "available" + InstallationUnavailable InstallationStatus = "unavailable" +) + // SessionCore exposes optional Core additions without changing official fields. type SessionCore struct { Installation *EnvironmentInstallation `json:"installation,omitempty"` @@ -8,11 +15,11 @@ type SessionCore struct { // EnvironmentInstallation contains short-lived, Environment-scoped commands. // Only authenticated creation and detail responses include this authorization. type EnvironmentInstallation struct { - Status string `json:"status" enums:"available,unavailable"` - Version string `json:"version"` - ExpiresAt int64 `json:"expires_at,omitempty"` - Commands map[string]string `json:"commands,omitempty"` - Message string `json:"message,omitempty"` + Status InstallationStatus `json:"status"` + Version string `json:"version"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Commands map[string]string `json:"commands,omitempty"` + Message string `json:"message,omitempty"` } // NativeInstallationContext is bootstrap metadata, not an execution protocol. diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index e42ba2e2f..961bb34e8 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -26,13 +26,6 @@ var modelProviderErrorCodes = map[string]string{ "max_output_tokens": "model_provider_token_limits_invalid", } -// Model provider sources, as recorded in a Session's execution configuration. -const ( - ModelProviderSourceSession = "session" - ModelProviderSourceAgent = "agent" - ModelProviderSourceDeployment = "deployment" -) - // SessionExecutionInput is a write-only execution extension, not a provider resource. type SessionExecutionInput struct { // Environment supplies placement-independent preparation through the Core extension. @@ -42,16 +35,16 @@ type SessionExecutionInput struct { } type ModelProviderInput struct { - Protocol string `json:"protocol" enums:"anthropic,responses,chat_completions" binding:"required"` - BaseURL string `json:"base_url" binding:"required"` - APIKey string `json:"api_key" binding:"required"` - ContextWindow int32 `json:"context_window,omitempty"` - MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` + Protocol modelprovider.Protocol `json:"protocol" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + APIKey string `json:"api_key" binding:"required"` + ContextWindow int32 `json:"context_window,omitempty"` + MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` } // Provider is the bundle as the Harness–Model provider protocol carries it. func (p *ModelProviderInput) Provider() modelprovider.Provider { - return modelprovider.Provider{Protocol: modelprovider.Protocol(p.Protocol), BaseURL: p.BaseURL, APIKey: p.APIKey, + return modelprovider.Provider{Protocol: p.Protocol, BaseURL: p.BaseURL, APIKey: p.APIKey, ContextWindow: p.ContextWindow, MaxOutputTokens: p.MaxOutputTokens} } @@ -76,10 +69,10 @@ func (p *ModelProviderInput) ValidateHarness(harness string) error { return err } configuration, _ := builtin.Registry().Lookup(harness) - if err := configuration.ValidateProtocol(p.Protocol); err != nil { + if err := configuration.ValidateProtocol(string(p.Protocol)); err != nil { return &ModelProviderError{Code: "model_provider_protocol_unsupported", Param: "protocol", message: err.Error()} } - if err := configuration.Validate(p.Protocol, p.ContextWindow, p.MaxOutputTokens); err != nil { + if err := configuration.Validate(string(p.Protocol), p.ContextWindow, p.MaxOutputTokens); err != nil { param := "max_output_tokens" if p.ContextWindow <= 0 { param = "context_window" diff --git a/contracts/agents-api/v1/model_execution_test.go b/contracts/agents-api/v1/model_execution_test.go index cc8b7a7d2..fd96c0fa6 100644 --- a/contracts/agents-api/v1/model_execution_test.go +++ b/contracts/agents-api/v1/model_execution_test.go @@ -1,12 +1,16 @@ package v1 -import "testing" +import ( + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" +) func TestModelExecutionValidation(t *testing.T) { for _, harness := range []string{"codex", "claude_sdk", "mcode"} { for _, protocol := range []string{"anthropic", "responses", "chat_completions"} { t.Run(harness+"/"+protocol, func(t *testing.T) { - p := ModelProviderInput{Protocol: protocol, BaseURL: "https://example.com", APIKey: "secret", ContextWindow: 200000, MaxOutputTokens: 8000} + p := ModelProviderInput{Protocol: modelprovider.Protocol(protocol), BaseURL: "https://example.com", APIKey: "secret", ContextWindow: 200000, MaxOutputTokens: 8000} native := harness == "mcode" || (harness == "codex" && protocol == "responses") || (harness == "claude_sdk" && protocol == "anthropic") if err := p.ValidateHarness(harness); (err == nil) != native { t.Fatalf("wrong native protocol admission: %v", err) @@ -22,7 +26,7 @@ func TestModelExecutionValidation(t *testing.T) { {"responses", ""}, {"responses", "unknown"}, {"unknown", "codex"}, {"openai", "codex"}, {"chat", "claude_sdk"}, {"chat-completions", "mcode"}, } { - p := ModelProviderInput{Protocol: tc.protocol, BaseURL: "https://example.com/v1", APIKey: "secret", ContextWindow: 200000, MaxOutputTokens: 8000} + p := ModelProviderInput{Protocol: modelprovider.Protocol(tc.protocol), BaseURL: "https://example.com/v1", APIKey: "secret", ContextWindow: 200000, MaxOutputTokens: 8000} if p.ValidateHarness(tc.harness) == nil { t.Fatalf("unsupported protocol or harness accepted: %s/%s", tc.protocol, tc.harness) } diff --git a/contracts/agents-api/v1/saved_core_extension.go b/contracts/agents-api/v1/saved_core_extension.go index 735295d6d..6cf2287fd 100644 --- a/contracts/agents-api/v1/saved_core_extension.go +++ b/contracts/agents-api/v1/saved_core_extension.go @@ -6,6 +6,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) // SavedAgentCoreInput carries defaults for future Sessions. The provider bundle @@ -24,11 +25,11 @@ type SavedAgentCore struct { } type ModelProviderView struct { - Protocol string `json:"protocol" enums:"anthropic,responses,chat_completions" binding:"required"` - BaseURL string `json:"base_url" binding:"required"` - ContextWindow int32 `json:"context_window,omitempty"` - MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` - APIKeyConfigured bool `json:"api_key_configured" binding:"required"` + Protocol modelprovider.Protocol `json:"protocol" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + ContextWindow int32 `json:"context_window,omitempty"` + MaxOutputTokens int32 `json:"max_output_tokens,omitempty"` + APIKeyConfigured bool `json:"api_key_configured" binding:"required"` } func (x *SavedAgentCoreInput) Validate() error { @@ -80,5 +81,5 @@ func (p *ModelProviderView) ValidateHarnessWithRegistry(harness string, registry if p == nil { return errors.New("model_provider is required") } - return registry.Validate(harness, p.Protocol, p.ContextWindow, p.MaxOutputTokens) + return registry.Validate(harness, string(p.Protocol), p.ContextWindow, p.MaxOutputTokens) } diff --git a/contracts/agents-api/v1/saved_core_extension_test.go b/contracts/agents-api/v1/saved_core_extension_test.go index 54a36b611..e253998e1 100644 --- a/contracts/agents-api/v1/saved_core_extension_test.go +++ b/contracts/agents-api/v1/saved_core_extension_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" ) func TestSavedProviderSafeView(t *testing.T) { @@ -32,7 +34,7 @@ func TestSavedProviderExplicitHarnessCompatibility(t *testing.T) { for _, protocol := range []string{"anthropic", "responses", "chat_completions"} { t.Run(harness+"/"+protocol, func(t *testing.T) { x := &SavedAgentCoreInput{Harness: harness, ModelProvider: &ModelProviderInput{ - Protocol: protocol, BaseURL: "https://example.test", APIKey: "fixture", ContextWindow: 100, MaxOutputTokens: 20, + Protocol: modelprovider.Protocol(protocol), BaseURL: "https://example.test", APIKey: "fixture", ContextWindow: 100, MaxOutputTokens: 20, }} native := harness == "" || harness == "mcode" || (harness == "codex" && protocol == "responses") || (harness == "claude_sdk" && protocol == "anthropic") if err := x.Validate(); (err == nil) != native { @@ -49,7 +51,7 @@ func TestSavedProviderExplicitHarnessCompatibility(t *testing.T) { for _, protocol := range []string{"anthropic", "responses", "chat_completions"} { for _, limits := range [][2]int32{{0, 0}, {100, 0}, {0, 20}} { x := &SavedAgentCoreInput{Harness: "mcode", ModelProvider: &ModelProviderInput{ - Protocol: protocol, BaseURL: "https://example.test", APIKey: "fixture", ContextWindow: limits[0], MaxOutputTokens: limits[1], + Protocol: modelprovider.Protocol(protocol), BaseURL: "https://example.test", APIKey: "fixture", ContextWindow: limits[0], MaxOutputTokens: limits[1], }} if x.Validate() == nil || x.SafeView().ModelProvider.ValidateHarness("mcode") == nil { t.Fatal("MiniMax limits must be complete for every upstream protocol") diff --git a/contracts/agents-api/v1/session_execution_configuration.go b/contracts/agents-api/v1/session_execution_configuration.go index 1a07b0e6c..a03fa238a 100644 --- a/contracts/agents-api/v1/session_execution_configuration.go +++ b/contracts/agents-api/v1/session_execution_configuration.go @@ -2,6 +2,23 @@ package v1 import "encoding/json" +type ExecutionProviderStatus string + +const ( + ExecutionProviderAvailable ExecutionProviderStatus = "available" + ExecutionProviderRedacted ExecutionProviderStatus = "redacted" + ExecutionProviderUnavailable ExecutionProviderStatus = "unavailable" +) + +type ExecutionSource string + +const ( + ExecutionSourceSession ExecutionSource = "session" + ExecutionSourceAgent ExecutionSource = "agent" + ExecutionSourceDeployment ExecutionSource = "deployment" + ExecutionSourceUnknown ExecutionSource = "unknown" +) + // SessionExecutionConfiguration describes committed configuration, not live // execution health. Unknown provenance is never reconstructed from defaults. type SessionExecutionConfiguration struct { @@ -15,20 +32,20 @@ type SessionExecutionConfiguration struct { } type ExecutionSelection struct { - Value *string `json:"value" binding:"required" extensions:"x-nullable"` - Source string `json:"source" binding:"required" enums:"session,agent,deployment,unknown"` + Value *string `json:"value" binding:"required" extensions:"x-nullable"` + Source ExecutionSource `json:"source" binding:"required"` } // Deployment credentials have no public endpoint projection. Unavailable means // no trustworthy safe provider snapshot was recorded, not failed readiness. type ExecutionProviderSelection struct { - Source string `json:"source" binding:"required" enums:"session,agent,deployment,unknown"` - Status string `json:"status" binding:"required" enums:"available,redacted,unavailable"` - Configuration *ModelProviderView `json:"configuration" binding:"required" extensions:"x-nullable"` + Source ExecutionSource `json:"source" binding:"required"` + Status ExecutionProviderStatus `json:"status" binding:"required"` + Configuration *ModelProviderView `json:"configuration" binding:"required" extensions:"x-nullable"` } // ExecutionHarnessConfigSelection records the immutable adapter parameters. type ExecutionHarnessConfigSelection struct { Value json.RawMessage `json:"value" swaggertype:"object" binding:"required"` - Source string `json:"source" binding:"required" enums:"session,agent,deployment,unknown"` + Source ExecutionSource `json:"source" binding:"required"` } diff --git a/contracts/agents-api/v1/upstream_contract_test.go b/contracts/agents-api/v1/upstream_contract_test.go index 9be78a569..140dd2c75 100644 --- a/contracts/agents-api/v1/upstream_contract_test.go +++ b/contracts/agents-api/v1/upstream_contract_test.go @@ -44,8 +44,9 @@ func TestPublicSchemaPreservesOfficialDefinitions(t *testing.T) { upstream := source["components"].(map[string]any)["schemas"].(map[string]any) schemas := public["components"].(map[string]any)["schemas"].(map[string]any) owners := map[string]bool{"AgentResource": true, "CreateAgentParams": true, "UpdateAgentParams": true, "SessionAgentConfigParam": true, "SessionAgentResource": true, "CreateAgentSessionParams": true, "SessionResource": true} + // Extension dependencies retain their producing Go package names. for name, value := range schemas { - if strings.HasPrefix(name, "v1.") { + if strings.HasPrefix(name, "v1.") || name == "modelprovider.Protocol" { continue } schema := value.(map[string]any) diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 76f496e68..80da43e72 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "Agents API 覆盖台账" source: contracts/agents-api/index.md -source_hash: 7f7326bcbc718daa44de601d9df6321d4e1a32fa713f081bc3877e4b23d000fd +source_hash: 480884468c15159c84b123978c1d8d5fdac89bd3833556100be1ef4d31fb8829 --- Core 旨在以下方固定版本为准支持完整的 OpenAI Agents API([public API rule](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#public-api))。本台账记录 Core 对各项资源实现了哪些内容、哪些契约保存其详细信息,并列出相对于 OpenAI 服务的所有已知差异和所有未解决缺口。[API namespaces and credentials](../../../docs/zh/api/index.md) 说明谁调用哪些 API;[Agents API guide](../../../docs/zh/api/public-agent-api.md) 介绍使用方法。 @@ -16,7 +16,7 @@ Core 旨在以下方固定版本为准支持完整的 OpenAI Agents API([publi | [openapi.yaml](../openapi.yaml) | 官方公共契约,并在 Agent 和 Session 请求及响应对象上加入 Core 的 `x_agents_core` 扩展 | | [go-bindings.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/contracts/agents-api/go-bindings.json) | Go 名称、字段表示、编码顺序和存储投影;不定义官方字段集合、枚举或约束 | -运行 `make openapi` 重新生成公共 Go 类型、Agent 请求结构、路由清单和三个 OpenAPI 文档。`scripts/generate-public-api.py` 读取仓库内经过校验和验证的官方源文件,无需网络。它选择 Agents、Vaults、Files 和 Skills,并跟随 schema 引用,保留联合类型、可空性、必填字段和约束。`services/core/internal/api/official_shapes.gen.go` 中的请求结构投影 `CreateAgentParams`、`UpdateAgentParams` 和 `SessionAgentConfigParam`;Core 在读取 Agent 配置前先按它们检查请求体。Core 在 `v1/` 中的扩展类型继续由 Go 定义,在生成时加入公共 schema。TypeScript 客户端在 `packages/agents-client/src/generated/public-api.ts` 中的类型、枚举值和字段名由生成后的公共 schema 生成。内部 `/core/v1` 和 `/api/v1` 文档由处理函数注解生成;同一生成器把 `/core/v1` 文档投影为 `packages/agents-client/src/generated/core-api.ts`,其中引用的公共类型从 `public-api.ts` 导入。在这些注解里,Core 总会发送的响应字段带 `binding:"required"`,可以为 null 的字段带 `extensions:"x-nullable"`,封闭集合带 `enums:`;这些标签只影响文档,客户端会拒绝不符合它们的响应。`make check-openapi` 检查生成结果是否最新并测试生成器;`make check-go` 也会运行此检查。 +运行 `make openapi` 重新生成公共 Go 类型、Agent 请求结构、路由清单和三个 OpenAPI 文档。`scripts/generate-public-api.py` 读取仓库内经过校验和验证的官方源文件,无需网络。它选择 Agents、Vaults、Files 和 Skills,并跟随 schema 引用,保留联合类型、可空性、必填字段和约束。`services/core/internal/api/official_shapes.gen.go` 中的请求结构投影 `CreateAgentParams`、`UpdateAgentParams` 和 `SessionAgentConfigParam`;Core 在读取 Agent 配置前先按它们检查请求体。Core 在 `v1/` 中的扩展类型继续由 Go 定义,在生成时加入公共 schema。TypeScript 客户端在 `packages/agents-client/src/generated/public-api.ts` 中的类型、枚举值和字段名由生成后的公共 schema 生成。内部 `/core/v1` 和 `/api/v1` 文档由处理函数注解生成;同一生成器把 `/core/v1` 文档投影为 `packages/agents-client/src/generated/core-api.ts`,其中引用的公共类型从 `public-api.ts` 导入。在这些注解里,Core 总会发送的响应字段带 `binding:"required"`,可以为 null 的字段带 `extensions:"x-nullable"`,Core 拥有的封闭集合使用生产方包中的具名 Go 类型和带类型的常量。Swag 从该类型生成一个枚举定义,供所有线上字段共享。单值判别字段保留 `enums:` 标签;映射官方或 Runtime 所有集合的字段保留标签,并通过契约测试与其所有者核对。这些注解只影响文档,客户端会拒绝不符合它们的响应。`make check-openapi` 检查生成结果是否最新并测试生成器;`make check-go` 也会运行此检查。 公共契约是官方 API 加上 Core 扩展。标准字段生成到 `v1/official.gen.go`;`go-bindings.json` 只列出 Core 使用的类型,仅在已有存储或自定义 JSON 编码需要时覆盖 Go 表示或字段顺序。未覆盖的字段遵循官方 schema,相同结构复用同一个 Go 类型。部分带判别字段的联合类型也从 schema 生成 JSON 序列化代码,保留每个分支必需的可空字段。其他联合类型序列化、Core 的本地限制、执行准入和状态转换仍由实现代码负责。契约测试验证公共 schema 保留官方定义、扩展位于 `x_agents_core` 中,且所有文档与注册路由一致。官方客户端和原始 HTTP 测试验证行为。生成 schema 不代表某个尚未实现的功能已经得到验证;下方缺口仍然适用。升级上游时,在比对和兼容性测试后一起更新 OpenAPI 和 SDK 固定版本。 diff --git a/packages/agents-client/src/admin-projection.ts b/packages/agents-client/src/admin-projection.ts index 24a725982..dd980fb88 100644 --- a/packages/agents-client/src/admin-projection.ts +++ b/packages/agents-client/src/admin-projection.ts @@ -8,11 +8,11 @@ import { addressBindingsFields, adminAssetCountsFields, adminauditOperationFields, adminauditPageFields, adminRuntimeObservationFields, adminRuntimeObservationListFields, adminSessionCountsFields, adminSummaryResponseFields, adminSummaryRowFields, adminUsageCoverageFields, coreHarnessFields, coreHarnessListFields, executorConnectionFields, executorConnectionStatusValues, executorCredentialFields, - executorCredentialListFields, harnessModelConfigurationFields, harnessModelConfigurationLastErrorCodeValues, installationConfigurationFields, - installationFields, installationSettingFields, installationSettingRequired, installationSettingRestartsValues, issuedExecutorCredentialFields, + executorCredentialListFields, harnessModelConfigurationFields, providerErrorCodeValues, installationConfigurationFields, + installationFields, installationSettingFields, installationSettingRequired, installationServiceValues, issuedExecutorCredentialFields, managedArchiveFields, managedArchiveStateValues, modelConfigurationSupportFields, projectFields, projectsAPIKeyFields, resourceOwnerFields, - resourceOwnerListFields, runtimeDiskObservationFields, writeauditAPIKeyFields, writeauditOperationActionValues, writeauditOperationFields, - writeauditOperationResourceTypeValues, writeauditPageFields, + resourceOwnerListFields, runtimeDiskObservationFields, writeauditAPIKeyFields, actionValues, writeauditOperationFields, + resourceTypeValues, writeauditPageFields, } from "./generated/core-api"; import type { CoreHarness, CoreHarnessKind, HarnessModelConfiguration, ListPage, SavedAgent, SessionArtifact } from "./types"; import type { @@ -119,7 +119,7 @@ export function projectWriteOperations(value: unknown): AdminWriteOperationPage const data = page.data.map((entry) => { const operation = record(entry, writeauditOperationFields); strings(operation, ["id", "created_at", "action", "resource_id", "parent_id", "request_id", "trace_id"]); - if (!date(operation.created_at) || !isOneOf(writeauditOperationActionValues, operation.action) || !isOneOf(writeauditOperationResourceTypeValues, operation.resource_type)) return invalidAdminResponse(); + if (!date(operation.created_at) || !isOneOf(actionValues, operation.action) || !isOneOf(resourceTypeValues, operation.resource_type)) return invalidAdminResponse(); return { ...operation, api_key: projectProvenance(operation.api_key) }; }); return { data, has_more: page.has_more, next_cursor: page.next_cursor } as AdminWriteOperationPage; @@ -212,7 +212,7 @@ export function projectHarnessModelConfiguration(value: unknown, harness?: CoreH const { object, harness: kind, updated_at, last_used_at, last_error_code, last_error_at, model_provider, model, harness_config } = value; if ((last_used_at !== null && (typeof last_used_at !== "string" || !date(last_used_at))) || (last_error_at !== null && (typeof last_error_at !== "string" || !date(last_error_at))) || - (last_error_code !== null && !isOneOf(harnessModelConfigurationLastErrorCodeValues, last_error_code)) || + (last_error_code !== null && !isOneOf(providerErrorCodeValues, last_error_code)) || ((last_error_code === null) !== (last_error_at === null))) return invalidAdminResponse(); if (object !== "core.model_configuration" || !isOneOf(coreHarnessKinds, kind) || (harness !== undefined && kind !== harness) || typeof updated_at !== "string" || !date(updated_at)) return invalidAdminResponse(); @@ -255,7 +255,7 @@ function projectInstallationSetting(value: unknown): CoreInstallationSetting { // `configured` is present exactly for a sensitive setting. const setting = record(value, value.sensitive ? installationSettingFields : installationSettingRequired); if (typeof setting.key !== "string" || !settingKey.test(setting.key) || typeof setting.changeable !== "boolean" || - !Array.isArray(setting.restarts) || !setting.restarts.every((service) => isOneOf(installationSettingRestartsValues, service)) || + !Array.isArray(setting.restarts) || !setting.restarts.every((service) => isOneOf(installationServiceValues, service)) || (setting.sensitive && (setting.value !== null || setting.default !== null || typeof setting.configured !== "boolean"))) return invalidAdminResponse(); return { ...setting } as unknown as CoreInstallationSetting; } diff --git a/packages/agents-client/src/admin-types.ts b/packages/agents-client/src/admin-types.ts index 266296360..2b5b5b939 100644 --- a/packages/agents-client/src/admin-types.ts +++ b/packages/agents-client/src/admin-types.ts @@ -2,12 +2,12 @@ import type { AddressBindings, AdminauditOperation, AdminauditPage, AdminRuntimeObservationDetail, AdminSessionArchiveRequest, AdminSummaryResponse, AdminSummaryRow, EnvironmentExecutorCredentialRequest, Installation, InstallationConfiguration, InstallationSetting, IssuedAPIKey, IssuedExecutorCredential as IssuedExecutorCredentialResource, ManagedArchive, Project, ProjectAPIKeyRequest, ProjectRequest, ProjectsAPIKey, - ResourceOwner, WriteauditAPIKey, WriteauditOperation, WriteauditOperationResourceType, WriteauditPage, + ResourceOwner, WriteauditAPIKey, WriteauditOperation, ResourceType, WriteauditPage, } from "./generated/core-api"; import type { PageOptions, RuntimeObservation } from "./types"; export type { ExecutorConnection, ExecutorCredential, ExecutorCredentialList, RuntimeDiskObservation } from "./generated/core-api"; -export { writeauditOperationResourceTypeValues as adminResourceTypes } from "./generated/core-api"; +export { resourceTypeValues as adminResourceTypes } from "./generated/core-api"; // Generated types keep their schema names in ./generated/core-api; these are the client's names for them. export type AdminProject = Project; @@ -19,7 +19,7 @@ export type IssueAdminAPIKeyInput = ProjectAPIKeyRequest; export type ArchiveAdminSessionInput = AdminSessionArchiveRequest; /** Current resource disposition; released does not imply that the active Turn has finalized. */ export type AdminSessionArchive = ManagedArchive; -export type AdminResourceType = WriteauditOperationResourceType; +export type AdminResourceType = ResourceType; export type AdminKeyProvenance = WriteauditAPIKey; /** `api_key` is null when Core has no creation record. */ export type AdminResourceOwner = ResourceOwner; diff --git a/packages/agents-client/src/core-metrics.ts b/packages/agents-client/src/core-metrics.ts index 43cd21e9e..f6859cfd1 100644 --- a/packages/agents-client/src/core-metrics.ts +++ b/packages/agents-client/src/core-metrics.ts @@ -2,7 +2,7 @@ import { AgentCoreError } from "./client"; import { CoreRequester, type CoreClientOptions } from "./core-request"; import { isOneOf } from "./response-projection"; import type { ReadOptions } from "./types"; -import { jobStatusValues, serviceStateStatusValues, type CoremetricsView, type JobStatus, type Latency, type ServiceState, type ServiceStateStatus } from "./generated/core-api"; +import { jobStatusValues, serviceStatusValues, type CoremetricsView, type JobStatus, type Latency, type ServiceState, type ServiceStatus } from "./generated/core-api"; function invalidCoreMetrics(): never { throw new AgentCoreError("Core metrics: the response is not JSON.", 0, "invalid_response"); @@ -19,7 +19,7 @@ export type CoreJobStatus = JobStatus; * null, never zero. The client reads a service status it does not recognise * as `unknown`, which is never shown as running. */ -export type CoreMetrics = Omit & { service: Omit & { status: ServiceStateStatus | "unknown" } }; +export type CoreMetrics = Omit & { service: Omit & { status: ServiceStatus | "unknown" } }; type Json = Record; @@ -67,7 +67,7 @@ export function projectCoreMetrics(value: unknown): CoreMetrics { const database = optional(body.database); const pool = optional(database.pool); const process = optional(body.process); - const status = isOneOf(serviceStateStatusValues, service.status) ? service.status : "unknown"; + const status = isOneOf(serviceStatusValues, service.status) ? service.status : "unknown"; const resolution = number(range.resolution_seconds); if (resolution === null || resolution <= 0) throw new AgentCoreError("Core metrics: range.resolution_seconds is missing.", 0, "invalid_response"); return { diff --git a/packages/agents-client/src/execution-configuration-projection.ts b/packages/agents-client/src/execution-configuration-projection.ts index efaca3016..e0a1506cc 100644 --- a/packages/agents-client/src/execution-configuration-projection.ts +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -1,8 +1,8 @@ import { modelProviderProtocols } from "./harness-catalog"; import { modelProviderViewFields } from "./generated/public-api"; import { - executionHarnessConfigSelectionFields, executionProviderSelectionFields, executionSelectionFields, executionSelectionSourceValues, - sessionExecutionConfigurationFields, type ExecutionSelectionSource, + executionHarnessConfigSelectionFields, executionProviderSelectionFields, executionSelectionFields, executionSourceValues, + sessionExecutionConfigurationFields, type ExecutionSource, } from "./generated/core-api"; import { canonicalUuid, exactFields, isNonnegativeInteger, isOneOf, isRecord, onlyFields, sameResourceId } from "./response-projection"; import type { ModelProviderView, SessionExecutionConfiguration } from "./types"; @@ -11,10 +11,10 @@ type Invalid = () => never; const protocols: ReadonlySet = new Set(modelProviderProtocols); function selection(value: unknown, invalid: Invalid): SessionExecutionConfiguration["model"] { - if (!isRecord(value) || !exactFields(value, executionSelectionFields) || !isOneOf(executionSelectionSourceValues, value.source) || + if (!isRecord(value) || !exactFields(value, executionSelectionFields) || !isOneOf(executionSourceValues, value.source) || (value.value !== null && (typeof value.value !== "string" || value.value.length === 0)) || (value.value === null && value.source !== "unknown")) return invalid(); - return { value: value.value as string | null, source: value.source as ExecutionSelectionSource }; + return { value: value.value as string | null, source: value.source as ExecutionSource }; } // Splits an absolute URL as RFC 3986 appendix B does, without parsing its host. @@ -56,7 +56,7 @@ export function projectExecutionConfiguration(value: unknown, sessionId: string, !isRecord(value.model_provider) || !exactFields(value.model_provider, executionProviderSelectionFields)) return invalid(); const native = value.harness_config; if (!isRecord(native) || !exactFields(native, executionHarnessConfigSelectionFields) || !isRecord(native.value) || - !isOneOf(executionSelectionSourceValues, native.source) || (native.source === "unknown" && Object.keys(native.value).length !== 0)) return invalid(); + !isOneOf(executionSourceValues, native.source) || (native.source === "unknown" && Object.keys(native.value).length !== 0)) return invalid(); const provider = value.model_provider; let configuration: ModelProviderView | null = null; if (provider.status === "available" && (provider.source === "session" || provider.source === "agent" || provider.source === "deployment")) { @@ -66,7 +66,7 @@ export function projectExecutionConfiguration(value: unknown, sessionId: string, return { object: "agent.session.execution_configuration", schema_version: 1, session_id: value.session_id, model: selection(value.model, invalid), harness: selection(value.harness, invalid), - harness_config: { value: { ...native.value }, source: native.source as ExecutionSelectionSource }, - model_provider: { source: provider.source as ExecutionSelectionSource, status: provider.status as SessionExecutionConfiguration["model_provider"]["status"], configuration }, + harness_config: { value: { ...native.value }, source: native.source as ExecutionSource }, + model_provider: { source: provider.source as ExecutionSource, status: provider.status as SessionExecutionConfiguration["model_provider"]["status"], configuration }, }; } diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts index 1aec23cce..1e26e408c 100644 --- a/packages/agents-client/src/generated/core-api.ts +++ b/packages/agents-client/src/generated/core-api.ts @@ -1,6 +1,8 @@ // Code generated by scripts/generate-public-api.py from contracts/agents-api/core.openapi.yaml; DO NOT EDIT. import type { ModelProviderInput, ModelProviderView, TokenUsageResource } from "./public-api"; +export const actionValues = ["create", "update", "delete", "send_events", "upload_file", "upload_version", "update_default_version"] as const; +export type Action = (typeof actionValues)[number]; export interface AddressBindings { hosted_sandboxes: number; nodes: number; @@ -169,6 +171,8 @@ export interface DatabaseBucket { start: string; } export const databaseBucketFields = ["ping_p95_ms", "pool_in_use", "start"] as const; +export const deploymentModeValues = ["", "nodes", "direct"] as const; +export type DeploymentMode = (typeof deploymentModeValues)[number]; export interface DeploymentResources { allocations: number; pending: number; @@ -187,7 +191,7 @@ export interface DeploymentView { generation: number; installation_id: string; metadata?: Record; - mode: DeploymentViewMode; + mode: DeploymentMode; owner_epoch: number; provider: string; reset: Reset | null; @@ -199,8 +203,6 @@ export interface DeploymentView { } export const deploymentViewFields = ["configuration", "core_url", "credential_configured", "generation", "installation_id", "metadata", "mode", "owner_epoch", "provider", "reset", "resources", "rollout", "specification", "specification_digest", "suspension"] as const; export const deploymentViewRequired = ["core_url", "credential_configured", "generation", "installation_id", "mode", "owner_epoch", "provider", "reset", "resources", "rollout", "suspension"] as const; -export const deploymentViewModeValues = ["", "nodes", "direct"] as const; -export type DeploymentViewMode = (typeof deploymentViewModeValues)[number]; export interface DiagnosticFailure { code: DiagnosticFailureCode; failed_at: string | null; @@ -209,6 +211,8 @@ export interface DiagnosticFailure { export const diagnosticFailureFields = ["code", "failed_at", "params"] as const; export const diagnosticFailureCodeValues = ["harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error", "environment_connection_timeout", "environment_unavailable", "environment_provisioning_failed", "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy", "connection_failed"] as const; export type DiagnosticFailureCode = (typeof diagnosticFailureCodeValues)[number]; +export const diagnosticSourceValues = ["turn", "environment", "environment_input"] as const; +export type DiagnosticSource = (typeof diagnosticSourceValues)[number]; export interface EnvironmentExecutorCredentialRequest { key_id: string; rotate?: boolean; @@ -237,29 +241,25 @@ export interface ExecutionBucket { } export const executionBucketFields = ["in_progress", "queue_wait_p95_ms", "queued", "start"] as const; export interface ExecutionHarnessConfigSelection { - source: ExecutionHarnessConfigSelectionSource; + source: ExecutionSource; value: Record; } export const executionHarnessConfigSelectionFields = ["source", "value"] as const; -export const executionHarnessConfigSelectionSourceValues = ["session", "agent", "deployment", "unknown"] as const; -export type ExecutionHarnessConfigSelectionSource = (typeof executionHarnessConfigSelectionSourceValues)[number]; export interface ExecutionProviderSelection { configuration: ModelProviderView | null; - source: ExecutionProviderSelectionSource; - status: ExecutionProviderSelectionStatus; + source: ExecutionSource; + status: ExecutionProviderStatus; } export const executionProviderSelectionFields = ["configuration", "source", "status"] as const; -export const executionProviderSelectionSourceValues = ["session", "agent", "deployment", "unknown"] as const; -export type ExecutionProviderSelectionSource = (typeof executionProviderSelectionSourceValues)[number]; -export const executionProviderSelectionStatusValues = ["available", "redacted", "unavailable"] as const; -export type ExecutionProviderSelectionStatus = (typeof executionProviderSelectionStatusValues)[number]; +export const executionProviderStatusValues = ["available", "redacted", "unavailable"] as const; +export type ExecutionProviderStatus = (typeof executionProviderStatusValues)[number]; export interface ExecutionSelection { - source: ExecutionSelectionSource; + source: ExecutionSource; value: string | null; } export const executionSelectionFields = ["source", "value"] as const; -export const executionSelectionSourceValues = ["session", "agent", "deployment", "unknown"] as const; -export type ExecutionSelectionSource = (typeof executionSelectionSourceValues)[number]; +export const executionSourceValues = ["session", "agent", "deployment", "unknown"] as const; +export type ExecutionSource = (typeof executionSourceValues)[number]; export interface ExecutorConnection { bound_key_id: string | null; enrolled_at: string | null; @@ -283,7 +283,7 @@ export interface HarnessModelConfiguration { harness: HarnessModelConfigurationHarness; harness_config: Record; last_error_at: string | null; - last_error_code: HarnessModelConfigurationLastErrorCode | null; + last_error_code: ProviderErrorCode | null; last_used_at: string | null; model: string; model_provider: ModelProviderView; @@ -293,8 +293,6 @@ export interface HarnessModelConfiguration { export const harnessModelConfigurationFields = ["harness", "harness_config", "last_error_at", "last_error_code", "last_used_at", "model", "model_provider", "object", "updated_at"] as const; export const harnessModelConfigurationHarnessValues = ["claude_sdk", "codex", "mcode"] as const; export type HarnessModelConfigurationHarness = (typeof harnessModelConfigurationHarnessValues)[number]; -export const harnessModelConfigurationLastErrorCodeValues = ["authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"] as const; -export type HarnessModelConfigurationLastErrorCode = (typeof harnessModelConfigurationLastErrorCodeValues)[number]; export interface HostHistory { points: HostHistoryPoint[]; resolution_seconds: number; @@ -322,19 +320,19 @@ export interface InstallationConfiguration { settings: InstallationSetting[]; } export const installationConfigurationFields = ["settings"] as const; +export const installationServiceValues = ["core", "web"] as const; +export type InstallationService = (typeof installationServiceValues)[number]; export interface InstallationSetting { changeable: boolean; configured?: boolean; default: unknown | null; key: string; - restarts: InstallationSettingRestarts[]; + restarts: InstallationService[]; sensitive: boolean; value: unknown | null; } export const installationSettingFields = ["changeable", "configured", "default", "key", "restarts", "sensitive", "value"] as const; export const installationSettingRequired = ["changeable", "default", "key", "restarts", "sensitive", "value"] as const; -export const installationSettingRestartsValues = ["core", "web"] as const; -export type InstallationSettingRestarts = (typeof installationSettingRestartsValues)[number]; export interface IssuedAPIKey { created_at: string; id: string; @@ -408,7 +406,7 @@ export interface Node { core_url: string; cpu_count: number | null; created_at: string; - diagnostic?: NodeDiagnostic; + diagnostic?: NodeDiagnosticCode; enrollment_id: string | null; id: string; last_seen_at: string | null; @@ -451,7 +449,7 @@ export interface NodeDetail { core_url: string; cpu_count: number | null; created_at: string; - diagnostic?: NodeDetailDiagnostic; + diagnostic?: NodeDiagnosticCode; enrollment_id: string | null; history: HostHistory; host: NodeHost; @@ -471,10 +469,8 @@ export interface NodeDetail { } export const nodeDetailFields = ["active", "available_disk_bytes", "available_memory_bytes", "cleanup_pending", "core_url", "cpu_count", "created_at", "diagnostic", "enrollment_id", "history", "host", "id", "last_seen_at", "max_active", "max_retained", "name", "online", "provider", "provider_ready", "reserved", "retained", "rollout", "running", "snapshots"] as const; export const nodeDetailRequired = ["active", "available_disk_bytes", "available_memory_bytes", "cleanup_pending", "core_url", "cpu_count", "created_at", "enrollment_id", "history", "host", "id", "last_seen_at", "max_active", "max_retained", "name", "online", "provider", "provider_ready", "reserved", "retained", "rollout", "running", "snapshots"] as const; -export const nodeDetailDiagnosticValues = ["provider_unavailable", "host_unsupported", "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", "capacity_insufficient"] as const; -export type NodeDetailDiagnostic = (typeof nodeDetailDiagnosticValues)[number]; -export const nodeDiagnosticValues = ["provider_unavailable", "host_unsupported", "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", "capacity_insufficient"] as const; -export type NodeDiagnostic = (typeof nodeDiagnosticValues)[number]; +export const nodeDiagnosticCodeValues = ["provider_unavailable", "host_unsupported", "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", "capacity_insufficient"] as const; +export type NodeDiagnosticCode = (typeof nodeDiagnosticCodeValues)[number]; export interface NodeHost { available_disk_bytes: number | null; available_memory_bytes: number | null; @@ -485,14 +481,12 @@ export interface NodeHost { } export const nodeHostFields = ["available_disk_bytes", "available_memory_bytes", "cpu_utilization", "effective_cpu_cores", "observed_at", "total_memory_bytes"] as const; export interface NodeRollout { - diagnostic?: NodeRolloutDiagnostic; + diagnostic?: NodeDiagnosticCode; ready_generation: number | null; state: NodeRolloutState; } export const nodeRolloutFields = ["diagnostic", "ready_generation", "state"] as const; export const nodeRolloutRequired = ["ready_generation", "state"] as const; -export const nodeRolloutDiagnosticValues = ["provider_unavailable", "host_unsupported", "artifacts_unavailable", "runtime_download_failed", "runtime_image_unavailable", "capacity_insufficient"] as const; -export type NodeRolloutDiagnostic = (typeof nodeRolloutDiagnosticValues)[number]; export const nodeRolloutStateValues = ["ready", "preparing", "failed", "update_required", "unknown"] as const; export type NodeRolloutState = (typeof nodeRolloutStateValues)[number]; export interface NodeUpdate { @@ -554,6 +548,8 @@ export interface ProjectsPage { has_more: boolean; } export const projectsPageFields = ["data", "has_more"] as const; +export const providerErrorCodeValues = ["authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"] as const; +export type ProviderErrorCode = (typeof providerErrorCodeValues)[number]; export interface Range { end: string; resolution_seconds: number; @@ -561,15 +557,15 @@ export interface Range { } export const rangeFields = ["end", "resolution_seconds", "start"] as const; export interface Reset { - clear: ResetClear; + clear: ResetMode; deadline_at: string | null; forced_at: string | null; remaining: ResetRemaining; requested_at: string; } export const resetFields = ["clear", "deadline_at", "forced_at", "remaining", "requested_at"] as const; -export const resetClearValues = ["auto", "force"] as const; -export type ResetClear = (typeof resetClearValues)[number]; +export const resetModeValues = ["auto", "force"] as const; +export type ResetMode = (typeof resetModeValues)[number]; export interface ResetOfflineNode { name: string; node_id: string; @@ -585,14 +581,12 @@ export interface ResetRemaining { } export const resetRemainingFields = ["busy", "cleanup", "idle", "offline_nodes", "on_offline_nodes"] as const; export interface ResetRequest { - clear: ResetRequestClear; + clear: ResetMode; deadline_seconds?: number; expected_generation: number; } export const resetRequestFields = ["clear", "deadline_seconds", "expected_generation"] as const; export const resetRequestRequired = ["clear", "expected_generation"] as const; -export const resetRequestClearValues = ["auto", "force"] as const; -export type ResetRequestClear = (typeof resetRequestClearValues)[number]; export interface ResourceOwner { api_key: WriteauditAPIKey | null; resource_id: string; @@ -602,6 +596,8 @@ export interface ResourceOwnerList { data: ResourceOwner[]; } export const resourceOwnerListFields = ["data"] as const; +export const resourceTypeValues = ["agent", "session", "environment", "environment_template", "skill", "skill_version", "file", "vault", "credential", "artifact"] as const; +export type ResourceType = (typeof resourceTypeValues)[number]; export interface Rollout { nodes: RolloutNodes | null; previous_generation_sandboxes: number; @@ -817,24 +813,22 @@ export interface ServiceState { execution_owner: boolean | null; revision: string | null; started_at: string | null; - status: ServiceStateStatus; + status: ServiceStatus; } export const serviceStateFields = ["execution_owner", "revision", "started_at", "status"] as const; -export const serviceStateStatusValues = ["running", "degraded"] as const; -export type ServiceStateStatus = (typeof serviceStateStatusValues)[number]; +export const serviceStatusValues = ["running", "degraded"] as const; +export type ServiceStatus = (typeof serviceStatusValues)[number]; export interface SessionDiagnosticFailure { code: SessionDiagnosticFailureCode; failed_at: string | null; params: Record; - source: SessionDiagnosticFailureSource; + source: DiagnosticSource; turn_id?: string; } export const sessionDiagnosticFailureFields = ["code", "failed_at", "params", "source", "turn_id"] as const; export const sessionDiagnosticFailureRequired = ["code", "failed_at", "params", "source"] as const; export const sessionDiagnosticFailureCodeValues = ["harness_error", "model_provider_required", "runtime_unavailable", "runtime_disconnected", "runtime_preparation_failed", "execution_interrupted", "delivery_unconfirmed", "input_rejected", "executor_protocol_error", "core_storage_failed", "internal_error", "environment_connection_timeout", "environment_unavailable", "environment_provisioning_failed", "authentication_error", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "invalid_request", "resource_not_found", "request_timeout", "context_length_exceeded", "cyber_policy", "connection_failed"] as const; export type SessionDiagnosticFailureCode = (typeof sessionDiagnosticFailureCodeValues)[number]; -export const sessionDiagnosticFailureSourceValues = ["turn", "environment", "environment_input"] as const; -export type SessionDiagnosticFailureSource = (typeof sessionDiagnosticFailureSourceValues)[number]; export interface SessionDiagnostics { failure: SessionDiagnosticFailure | null; object: "core.session_diagnostics"; @@ -878,21 +872,17 @@ export interface WriteauditAPIKey { } export const writeauditAPIKeyFields = ["id", "kind", "name", "prefix", "revoked_at"] as const; export interface WriteauditOperation { - action: WriteauditOperationAction; + action: Action; api_key: WriteauditAPIKey; created_at: string; id: string; parent_id: string; request_id: string; resource_id: string; - resource_type: WriteauditOperationResourceType; + resource_type: ResourceType; trace_id: string; } export const writeauditOperationFields = ["action", "api_key", "created_at", "id", "parent_id", "request_id", "resource_id", "resource_type", "trace_id"] as const; -export const writeauditOperationActionValues = ["create", "update", "delete", "send_events", "upload_file", "upload_version", "update_default_version"] as const; -export type WriteauditOperationAction = (typeof writeauditOperationActionValues)[number]; -export const writeauditOperationResourceTypeValues = ["agent", "session", "environment", "environment_template", "skill", "skill_version", "file", "vault", "credential", "artifact"] as const; -export type WriteauditOperationResourceType = (typeof writeauditOperationResourceTypeValues)[number]; export interface WriteauditPage { data: WriteauditOperation[]; has_more: boolean; diff --git a/packages/agents-client/src/generated/public-api.ts b/packages/agents-client/src/generated/public-api.ts index 4edc43fd2..80d6fa6c0 100644 --- a/packages/agents-client/src/generated/public-api.ts +++ b/packages/agents-client/src/generated/public-api.ts @@ -352,7 +352,7 @@ export interface EnvironmentInstallation { commands?: Record; expires_at?: number; message?: string; - status?: "available" | "unavailable"; + status?: InstallationStatus; version?: string; } export const environmentInstallationFields = ["commands", "expires_at", "message", "status", "version"] as const; @@ -639,6 +639,8 @@ export interface InputTokensDetailsResource { cached_tokens: number; } export const inputTokensDetailsResourceFields = ["cached_tokens"] as const; +export const installationStatusValues = ["available", "unavailable"] as const; +export type InstallationStatus = (typeof installationStatusValues)[number]; export interface InterruptSubagentCallItemResource { type: "interrupt_subagent_call"; id: string; @@ -768,7 +770,7 @@ export interface ModelProviderInput { base_url: string; context_window?: number; max_output_tokens?: number; - protocol: "anthropic" | "responses" | "chat_completions"; + protocol: Protocol; } export const modelProviderInputFields = ["api_key", "base_url", "context_window", "max_output_tokens", "protocol"] as const; export const modelProviderInputRequired = ["api_key", "base_url", "protocol"] as const; @@ -777,7 +779,7 @@ export interface ModelProviderView { base_url: string; context_window?: number; max_output_tokens?: number; - protocol: "anthropic" | "responses" | "chat_completions"; + protocol: Protocol; } export const modelProviderViewFields = ["api_key_configured", "base_url", "context_window", "max_output_tokens", "protocol"] as const; export const modelProviderViewRequired = ["api_key_configured", "base_url", "protocol"] as const; @@ -943,6 +945,8 @@ export interface PersistedMcpTransportResourceStdio { env_vars: string[]; } export const persistedMcpTransportResourceStdioFields = ["type", "command", "args", "cwd", "env_vars"] as const; +export const protocolValues = ["anthropic", "responses", "chat_completions"] as const; +export type Protocol = (typeof protocolValues)[number]; export interface PublicEnvironmentResource { id: string; object: "agent.environment"; diff --git a/packages/agents-client/src/sandbox-client.ts b/packages/agents-client/src/sandbox-client.ts index 8445140a0..9d3f08fa8 100644 --- a/packages/agents-client/src/sandbox-client.ts +++ b/packages/agents-client/src/sandbox-client.ts @@ -3,25 +3,25 @@ import { CoreRequester, type CoreClientOptions } from "./core-request"; import { deploymentContract } from "./deployment-contract"; import { hasOwn, isNonnegativeInteger, isOneOf, isRecord, onlyFields, sameResourceId, schemaFields } from "./response-projection"; import { - deploymentResourcesFields, deploymentSpecFields, deploymentSpecRequired, deploymentViewFields, deploymentViewModeValues, deploymentViewRequired, + deploymentResourcesFields, deploymentSpecFields, deploymentSpecRequired, deploymentViewFields, deploymentModeValues, deploymentViewRequired, hostHistoryFields, hostHistoryPointFields, nodeAllocationDiagnosticValues, nodeAllocationFields, nodeDetailFields, nodeDetailRequired, - nodeDiagnosticValues, nodeFields, nodeHostFields, nodeRequired, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetClearValues, + nodeDiagnosticCodeValues, nodeFields, nodeHostFields, nodeRequired, nodeRolloutFields, nodeRolloutRequired, nodeRolloutStateValues, resetModeValues, resetFields, resetOfflineNodeFields, resetRemainingFields, rolloutFields, rolloutNodesFields, rolloutStateValues, runtimeReleaseFields, sandboxAllocationListFields, sandboxNodeListFields, sandboxResourcesFields, sandboxResourcesRequired, suspensionFields, - type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnostic, + type DeploymentSpec, type DeploymentView, type HostHistoryPoint, type Node, type NodeAllocation, type NodeDetail, type NodeDiagnosticCode, type NodeHost, type NodeRollout, type NodeUpdate, type Reset, type ResetOfflineNode, type ResetRequest, type Rollout, type RuntimeRelease, type SandboxDeploymentInput, type SandboxEnrollmentToken, type SandboxResources as SandboxResourcesResource, } from "./generated/core-api"; import type { ReadOptions } from "./types"; /** Checked against Core's shared node-diagnostics.json fixture. */ -export const sandboxNodeDiagnostics = nodeDiagnosticValues; +export const sandboxNodeDiagnostics = nodeDiagnosticCodeValues; /** 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 = NodeDiagnostic; +export type SandboxNodeDiagnostic = NodeDiagnosticCode; /** Keep a known readiness cause; never expose unclassified node-supplied text. */ export function normalizeSandboxNodeDiagnostic(value: string): SandboxNodeDiagnostic { - return isOneOf(nodeDiagnosticValues, value) ? value : "provider_unavailable"; + return isOneOf(nodeDiagnosticCodeValues, value) ? value : "provider_unavailable"; } /** A registered Provider kind; deploymentContract.providers holds each one's declaration. */ @@ -121,7 +121,7 @@ function projectReset(value: unknown, held: number): SandboxReset | null { if (value === null) return null; const reset = members(value, resetFields); const remaining = members(reset.remaining, resetRemainingFields); - valid(isOneOf(resetClearValues, reset.clear) && timestamp(reset.requested_at) && + valid(isOneOf(resetModeValues, reset.clear) && timestamp(reset.requested_at) && nullable(timestamp)(reset.deadline_at) && nullable(timestamp)(reset.forced_at) && (reset.clear === "auto" ? reset.deadline_at !== null && reset.forced_at === null : reset.forced_at !== null) && [remaining.busy, remaining.idle, remaining.cleanup, remaining.on_offline_nodes].every(isNonnegativeInteger) && Array.isArray(remaining.offline_nodes)); @@ -161,7 +161,7 @@ function projectDeployment(value: unknown): SandboxDeployment { const resources = members(deployment.resources, deploymentResourcesFields); const suspension = deployment.suspension === null ? null : members(deployment.suspension, suspensionFields); const configured = hasOwn(deployment, "specification"); - valid(strings(deployment, ["installation_id", "core_url"]) && (deployment.provider === "" || (typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider))) && isOneOf(deploymentViewModeValues, deployment.mode) && + valid(strings(deployment, ["installation_id", "core_url"]) && (deployment.provider === "" || (typeof deployment.provider === "string" && hasOwn(deploymentContract.providers, deployment.provider))) && isOneOf(deploymentModeValues, deployment.mode) && [deployment.owner_epoch, deployment.generation, resources.allocations, resources.pending].every(isNonnegativeInteger) && (suspension === null || [suspension.idle_seconds, suspension.retention_seconds].every(isNonnegativeInteger)) && configured === hasOwn(deployment, "specification_digest") && (!configured || (typeof deployment.specification_digest === "string" && deployment.specification_digest !== ""))); diff --git a/packages/agents-client/src/session-diagnostics.ts b/packages/agents-client/src/session-diagnostics.ts index f2793ca82..27ed6925d 100644 --- a/packages/agents-client/src/session-diagnostics.ts +++ b/packages/agents-client/src/session-diagnostics.ts @@ -3,7 +3,7 @@ import { invalidAdminResponse } from "./admin-projection"; import { turnStatusResourceValues } from "./generated/public-api"; import { diagnosticFailureFields, itemDiagnosticTimingFields, sessionDiagnosticFailureFields, sessionDiagnosticFailureRequired, - sessionDiagnosticFailureSourceValues, sessionDiagnosticsFields, sessionDiagnosticsStatusValues, turnDiagnosticsFields, + diagnosticSourceValues, sessionDiagnosticsFields, sessionDiagnosticsStatusValues, turnDiagnosticsFields, type DiagnosticFailure as DiagnosticFailureResource, type DiagnosticFailureCode, type ItemDiagnosticTiming, type SessionDiagnosticFailure as SessionDiagnosticFailureResource, type SessionDiagnostics as SessionDiagnosticsResource, type TurnDiagnostics as TurnDiagnosticsResource, @@ -50,7 +50,7 @@ export function projectSessionDiagnostics(value: unknown, sessionId: string): Se canonicalUuid(value.session_id) === null || !sameResourceId(value.session_id as string, sessionId) || !isOneOf(sessionDiagnosticsStatusValues, value.status)) return invalidAdminResponse(); let projected: SessionDiagnosticFailure | null = null; if (value.status === "failed") { - if (!isRecord(value.failure) || !isOneOf(sessionDiagnosticFailureSourceValues, value.failure.source)) return invalidAdminResponse(); + if (!isRecord(value.failure) || !isOneOf(diagnosticSourceValues, value.failure.source)) return invalidAdminResponse(); const f = value.failure; const source = value.failure.source; projected = { ...failure(f, source === "turn" ? turnCodes : source === "environment_input" ? inputCodes : environmentCodes, true), source }; diff --git a/scripts/generate-public-api.test.py b/scripts/generate-public-api.test.py index dd039c572..48ebbed83 100644 --- a/scripts/generate-public-api.test.py +++ b/scripts/generate-public-api.test.py @@ -23,7 +23,7 @@ def test_generated_types_are_current(self): def test_public_projection_is_current_and_keeps_source_immutable(self): source = copy.deepcopy(self.source) - extensions = {k: v for k, v in self.public['components']['schemas'].items() if k.startswith('v1.')} + extensions = {k: v for k, v in self.public['components']['schemas'].items() if k not in source['components']['schemas']} actual = generator.public_document(source, extensions, generator.extension_owners(self.bindings), self.pin["beta_header"]) self.assertEqual(actual, self.public) self.assertEqual(source, self.source) diff --git a/services/core/cmd/server/managed_nodes.go b/services/core/cmd/server/managed_nodes.go index 74daae230..905fc1ca7 100644 --- a/services/core/cmd/server/managed_nodes.go +++ b/services/core/cmd/server/managed_nodes.go @@ -63,5 +63,5 @@ func configureManagedNodes(nodes *deployment.Service, reader deployment.Reader, } func nodeHealthRecord(health node.Health) deployment.NodeHealth { - return deployment.NodeHealth{Host: &deployment.NodeHost{EffectiveCPUCores: health.EffectiveCPUCores, CPUUtilization: health.CPUUtilization, TotalMemoryBytes: health.TotalMemoryBytes, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes, ObservedAt: &health.ObservedAt}, ProviderReady: health.ProviderReady, Diagnostic: health.Diagnostic, CPUCount: health.CPUCount, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes} + return deployment.NodeHealth{Host: &deployment.NodeHost{EffectiveCPUCores: health.EffectiveCPUCores, CPUUtilization: health.CPUUtilization, TotalMemoryBytes: health.TotalMemoryBytes, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes, ObservedAt: &health.ObservedAt}, ProviderReady: health.ProviderReady, Diagnostic: sandbox.NodeDiagnosticCode(health.Diagnostic), CPUCount: health.CPUCount, AvailableMemoryBytes: health.AvailableMemoryBytes, AvailableDiskBytes: health.AvailableDiskBytes} } diff --git a/services/core/internal/api/environment_executor_management.go b/services/core/internal/api/environment_executor_management.go index 3b7ea6b4f..51f498cd8 100644 --- a/services/core/internal/api/environment_executor_management.go +++ b/services/core/internal/api/environment_executor_management.go @@ -13,6 +13,14 @@ import ( "github.com/google/uuid" ) +type ExecutorConnectionStatus string + +const ( + ExecutorNeverEnrolled ExecutorConnectionStatus = "never_enrolled" + ExecutorConnected ExecutorConnectionStatus = "connected" + ExecutorDisconnected ExecutorConnectionStatus = "disconnected" +) + // ExecutorConnections observes current executor authority and the enrolled // sandbox's serve peer at the Link relay. The observer runs after the // Environments snapshot closes and must recheck authority after inspecting @@ -35,9 +43,9 @@ type ExecutorCredentialList struct { // ExecutorConnection reports the Environment's enrollment and whether its // sandbox serves the Environment now. type ExecutorConnection struct { - Status string `json:"status" binding:"required" enums:"never_enrolled,connected,disconnected"` - BoundKeyID *string `json:"bound_key_id" binding:"required" extensions:"x-nullable" format:"uuid"` - EnrolledAt *time.Time `json:"enrolled_at" binding:"required" format:"date-time" extensions:"x-nullable"` + Status ExecutorConnectionStatus `json:"status" binding:"required"` + BoundKeyID *string `json:"bound_key_id" binding:"required" extensions:"x-nullable" format:"uuid"` + EnrolledAt *time.Time `json:"enrolled_at" binding:"required" format:"date-time" extensions:"x-nullable"` } // registerExecutorCredentialRoutes adds executor credential issuance to the @@ -70,10 +78,10 @@ func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request writeSessionsError(w, r, err) return } - connection := ExecutorConnection{Status: "never_enrolled"} + connection := ExecutorConnection{Status: ExecutorNeverEnrolled} observed := state.Connection if observed.Enrolled { - connection = ExecutorConnection{Status: "disconnected", BoundKeyID: observed.BoundKeyID, EnrolledAt: observed.EnrolledAt} + connection = ExecutorConnection{Status: ExecutorDisconnected, BoundKeyID: observed.BoundKeyID, EnrolledAt: observed.EnrolledAt} if observed.CredentialHash != "" { connected, err := h.ExecutorConnections.ExecutorConnected(r.Context(), state.EnvironmentID, observed.CredentialHash) if err != nil && !errors.Is(err, sessions.ErrNotFound) && !errors.Is(err, sessions.ErrDeviceBindingConflict) { @@ -81,7 +89,7 @@ func (h *Handler) listExecutorCredentials(w http.ResponseWriter, r *http.Request return } if err == nil && connected { - connection.Status = "connected" + connection.Status = ExecutorConnected } } } diff --git a/services/core/internal/api/environment_executor_management_test.go b/services/core/internal/api/environment_executor_management_test.go index dc8b65de7..1ff03e88e 100644 --- a/services/core/internal/api/environment_executor_management_test.go +++ b/services/core/internal/api/environment_executor_management_test.go @@ -172,7 +172,7 @@ func TestExecutorConnectionListObservation(t *testing.T) { } if tc.status == 200 { var got ExecutorCredentialList - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.Connection.Status != tc.want || got.Connection.BoundKeyID == nil || *got.Connection.BoundKeyID != bound { + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || string(got.Connection.Status) != tc.want || got.Connection.BoundKeyID == nil || *got.Connection.BoundKeyID != bound { t.Fatal("projection", err, w.Body) } } diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index 61624c84c..b90e80a55 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -27,7 +27,7 @@ type NativeInstaller struct { func (h *Handler) installationFor(ctx context.Context, principal identity.Principal, environment string) (*v1.EnvironmentInstallation, error) { installer := h.Execution.NativeInstaller - result := &v1.EnvironmentInstallation{Status: "unavailable", Message: "This Core has no matching native installation distribution. Ask its operator to install the qualified release artifacts."} + result := &v1.EnvironmentInstallation{Status: v1.InstallationUnavailable, Message: "This Core has no matching native installation distribution. Ask its operator to install the qualified release artifacts."} if installer == nil { return result, nil } @@ -39,7 +39,7 @@ func (h *Handler) installationFor(ctx context.Context, principal identity.Princi if err != nil { return nil, err } - return &v1.EnvironmentInstallation{Status: "available", Version: installer.Version, ExpiresAt: expires, Commands: installer.Catalog.Commands(installer.Base, token)}, nil + return &v1.EnvironmentInstallation{Status: v1.InstallationAvailable, Version: installer.Version, ExpiresAt: expires, Commands: installer.Catalog.Commands(installer.Base, token)}, nil } func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, response *v1.Session) error { diff --git a/services/core/internal/api/harness_model_providers.go b/services/core/internal/api/harness_model_providers.go index c88a372c3..bdf7f07c0 100644 --- a/services/core/internal/api/harness_model_providers.go +++ b/services/core/internal/api/harness_model_providers.go @@ -32,10 +32,10 @@ type HarnessModelConfiguration struct { Object string `json:"object" enums:"core.model_configuration" binding:"required"` Harness string `json:"harness" binding:"required"` v1.ModelConfigurationView - LastUsedAt *time.Time `json:"last_used_at" format:"date-time" extensions:"x-nullable" binding:"required"` - LastErrorCode *string `json:"last_error_code" extensions:"x-nullable" binding:"required" enums:"authentication_error,connection_failed,rate_limit_exceeded,usage_limit_exceeded,server_overloaded,server_error,resource_not_found,request_timeout,invalid_request"` - LastErrorAt *time.Time `json:"last_error_at" format:"date-time" extensions:"x-nullable" binding:"required"` - UpdatedAt time.Time `json:"updated_at" binding:"required"` + LastUsedAt *time.Time `json:"last_used_at" format:"date-time" extensions:"x-nullable" binding:"required"` + LastErrorCode *modelconfiguration.ProviderErrorCode `json:"last_error_code" extensions:"x-nullable" binding:"required"` + LastErrorAt *time.Time `json:"last_error_at" format:"date-time" extensions:"x-nullable" binding:"required"` + UpdatedAt time.Time `json:"updated_at" binding:"required"` } // CoreHarness describes one harness this build supports. Enabled and default diff --git a/services/core/internal/api/inputs.go b/services/core/internal/api/inputs.go index 5bc15f86e..8d300e5ed 100644 --- a/services/core/internal/api/inputs.go +++ b/services/core/internal/api/inputs.go @@ -9,6 +9,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) @@ -50,7 +51,7 @@ func (h *Handler) createEvents(w http.ResponseWriter, r *http.Request) { writeSessionsError(w, r, err) return } - if !h.auditSessionOperation(w, r, chi.URLParam(r, "session_id"), "send_events") { + if !h.auditSessionOperation(w, r, chi.URLParam(r, "session_id"), string(writeaudit.ActionSendEvents)) { return } w.Header().Set("Cache-Control", "no-store") diff --git a/services/core/internal/api/installation.go b/services/core/internal/api/installation.go index e99e574ed..aae2c35d9 100644 --- a/services/core/internal/api/installation.go +++ b/services/core/internal/api/installation.go @@ -7,6 +7,13 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" ) +type InstallationService string + +const ( + InstallationCore InstallationService = "core" + InstallationWeb InstallationService = "web" +) + // Installation reports Core's installation facts. Core reads them from its // environment and build; configuration is the process settings it loaded. type Installation struct { @@ -43,7 +50,7 @@ type InstallationSetting struct { Changeable bool `json:"changeable" binding:"required"` Sensitive bool `json:"sensitive" binding:"required"` // Services that restart when the setting changes. - Restarts []string `json:"restarts" enums:"core,web" binding:"required"` + Restarts []InstallationService `json:"restarts" binding:"required"` } // InstallationBindings counts what is bound to the current public URL. diff --git a/services/core/internal/api/installation_test.go b/services/core/internal/api/installation_test.go index 8f65dcce9..b6e35df20 100644 --- a/services/core/internal/api/installation_test.go +++ b/services/core/internal/api/installation_test.go @@ -19,7 +19,7 @@ func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) { fakes.projectsReader.resolveAPIKey = projectKeys(t, callerBinding()).ResolveAPIKey deps.CoreKeys = coreKeys(t, "administrator") public, id := "https://core.example", "5b7c0f3e-0000-4000-8000-000000000001" - settings := InstallationConfiguration{Settings: []InstallationSetting{{Key: "ports.core", Value: 8091, Default: 8091, Changeable: true, Restarts: []string{"core"}}}} + settings := InstallationConfiguration{Settings: []InstallationSetting{{Key: "ports.core", Value: 8091, Default: 8091, Changeable: true, Restarts: []InstallationService{"core"}}}} fakes.installationBindings.addressBindings = func(context.Context) (deployment.AddressBindings, error) { return deployment.AddressBindings{Nodes: 2, NodesOnOtherAddress: 1}, nil } diff --git a/services/core/internal/api/sandbox_deployment_setup.go b/services/core/internal/api/sandbox_deployment_setup.go index 13b791326..cd899671b 100644 --- a/services/core/internal/api/sandbox_deployment_setup.go +++ b/services/core/internal/api/sandbox_deployment_setup.go @@ -132,9 +132,9 @@ func (h *Handler) startSandboxReset(w http.ResponseWriter, r *http.Request) { return } var input struct { - ExpectedGeneration *uint64 `json:"expected_generation"` - Clear string `json:"clear"` - DeadlineSeconds *int32 `json:"deadline_seconds"` + ExpectedGeneration *uint64 `json:"expected_generation"` + Clear deployment.ResetMode `json:"clear"` + DeadlineSeconds *int32 `json:"deadline_seconds"` } if decodeInputObject(raw, &input, "expected_generation", "clear", "deadline_seconds") != nil || input.ExpectedGeneration == nil { writeError(w, http.StatusBadRequest, "invalid_request_error", "A current expected_generation is required.", "expected_generation") diff --git a/services/core/internal/api/saved_provider_test.go b/services/core/internal/api/saved_provider_test.go index 4e2c8494b..0dc981eb6 100644 --- a/services/core/internal/api/saved_provider_test.go +++ b/services/core/internal/api/saved_provider_test.go @@ -172,14 +172,14 @@ func TestSavedProviderProtocolHarnessMatrix(t *testing.T) { if path == "/v1/agents" { want = http.StatusCreated } - if response.Code != want || s.provider == nil || s.provider.Protocol != protocol || s.provider.APIKey != "saved-provider-secret" { + if response.Code != want || s.provider == nil || string(s.provider.Protocol) != protocol || s.provider.APIKey != "saved-provider-secret" { t.Fatalf("provider bundle rejected or changed: status=%d", response.Code) } assertSavedProviderRedacted(t, response.Body.String()) var result struct { Core v1.SavedAgentCore `json:"x_agents_core"` } - if json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Core.Harness != harness || result.Core.ModelProvider == nil || result.Core.ModelProvider.Protocol != protocol { + if json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Core.Harness != harness || result.Core.ModelProvider == nil || string(result.Core.ModelProvider.Protocol) != protocol { t.Fatal("safe view changed the selected harness or upstream protocol") } } diff --git a/services/core/internal/api/session_diagnostics.go b/services/core/internal/api/session_diagnostics.go index 759e6aab4..dd5ad70d3 100644 --- a/services/core/internal/api/session_diagnostics.go +++ b/services/core/internal/api/session_diagnostics.go @@ -10,6 +10,14 @@ import ( "github.com/go-chi/chi/v5" ) +type DiagnosticSource string + +const ( + DiagnosticTurn DiagnosticSource = "turn" + DiagnosticEnvironment DiagnosticSource = "environment" + DiagnosticEnvironmentInput DiagnosticSource = "environment_input" +) + type DiagnosticFailure struct { Code string `json:"code" binding:"required" enums:"harness_error,model_provider_required,runtime_unavailable,runtime_disconnected,runtime_preparation_failed,execution_interrupted,delivery_unconfirmed,input_rejected,executor_protocol_error,core_storage_failed,internal_error,environment_connection_timeout,environment_unavailable,environment_provisioning_failed,authentication_error,rate_limit_exceeded,usage_limit_exceeded,server_overloaded,server_error,invalid_request,resource_not_found,request_timeout,context_length_exceeded,cyber_policy,connection_failed"` Params CoreErrorDetails `json:"params" swaggertype:"object" binding:"required"` @@ -18,8 +26,8 @@ type DiagnosticFailure struct { type SessionDiagnosticFailure struct { DiagnosticFailure - Source string `json:"source" binding:"required" enums:"turn,environment,environment_input"` - TurnID string `json:"turn_id,omitempty"` + Source DiagnosticSource `json:"source" binding:"required"` + TurnID string `json:"turn_id,omitempty"` } type SessionDiagnostics struct { @@ -82,12 +90,12 @@ func (h *Handler) getSessionDiagnostics(w http.ResponseWriter, r *http.Request) failure := SessionDiagnosticFailure{DiagnosticFailure: DiagnosticFailure{Code: "internal_error", Params: CoreErrorDetails{}}} switch { case session.EnvironmentFailure != nil: - failure.Source = "environment" + failure.Source = DiagnosticEnvironment failure.Code = "environment_provisioning_failed" failure.FailedAt = diagnosticTime(session.EnvironmentFailure.FailedAt) failure.Params = provisioningFailureParams(session.EnvironmentFailure.Detail) case session.EnvironmentInputActivity != nil: - failure.Source = "environment_input" + failure.Source = DiagnosticEnvironmentInput failure.FailedAt = diagnosticTime(session.EnvironmentInputActivity.LastActiveAt) switch session.EnvironmentInputActivity.Failure { case "": @@ -100,7 +108,7 @@ func (h *Handler) getSessionDiagnostics(w http.ResponseWriter, r *http.Request) failure.Code = "model_provider_required" } case session.LastTurn != nil: - failure.Source, failure.TurnID = "turn", session.LastTurn.ID + failure.Source, failure.TurnID = DiagnosticTurn, session.LastTurn.ID failure.DiagnosticFailure = *turnDiagnosticFailure(*session.LastTurn) } response.Failure = &failure diff --git a/services/core/internal/api/session_diagnostics_test.go b/services/core/internal/api/session_diagnostics_test.go index 3265feb8a..becdf229e 100644 --- a/services/core/internal/api/session_diagnostics_test.go +++ b/services/core/internal/api/session_diagnostics_test.go @@ -3,10 +3,12 @@ package api import ( "context" "encoding/json" + "reflect" "strings" "testing" "time" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" @@ -122,3 +124,11 @@ func TestDiagnosticsHostedFailureOverridesInputWithoutParsingReason(t *testing.T } } } + +func TestDiagnosticStatusMatchesOfficialSession(t *testing.T) { + official, _ := reflect.TypeFor[v1.Session]().FieldByName("Status") + diagnostic, _ := reflect.TypeFor[SessionDiagnostics]().FieldByName("Status") + if diagnostic.Type != official.Type || diagnostic.Tag.Get("enums") != official.Tag.Get("enums") { + t.Fatal("diagnostics must preserve the pinned official Session status set") + } +} diff --git a/services/core/internal/api/session_execution_configuration.go b/services/core/internal/api/session_execution_configuration.go index 8a194e174..b50149b72 100644 --- a/services/core/internal/api/session_execution_configuration.go +++ b/services/core/internal/api/session_execution_configuration.go @@ -18,43 +18,32 @@ func sessionExecutionProjection(input sessionRequest, saved *v1.SavedAgent, inhe } `json:"agent"` } _ = json.Unmarshal(raw, &configuration) // The resolved configuration was already validated. - modelSource := "session" - if saved != nil && (input.Agent == nil || input.Agent.Model == nil) { - modelSource = "agent" - } - if input.modelSource != "" { - modelSource = input.modelSource - } - harnessSource := "deployment" + harnessSource := v1.ExecutionSourceDeployment if _, overridden := input.agentFields["x_agents_core"]; overridden { if input.Agent != nil && input.Agent.XAgentsCore != nil && input.Agent.XAgentsCore.Harness != "" { - harnessSource = "session" + harnessSource = v1.ExecutionSourceSession } else if input.Agent != nil && input.Agent.XAgentsCore != nil && saved != nil && saved.XAgentsCore != nil && saved.XAgentsCore.Harness != "" { - harnessSource = "agent" + harnessSource = v1.ExecutionSourceAgent } } else if saved != nil && saved.XAgentsCore != nil && saved.XAgentsCore.Harness != "" { - harnessSource = "agent" + harnessSource = v1.ExecutionSourceAgent } // Deployment defaults are readable with the same Core key, so new Sessions // record their safe view too; historical rows stay redacted. - selection := v1.ExecutionProviderSelection{Source: "deployment", Status: "available", Configuration: provider.SafeView()} + selection := v1.ExecutionProviderSelection{Source: v1.ExecutionSourceDeployment, Status: v1.ExecutionProviderAvailable, Configuration: provider.SafeView()} if input.XAgentsCore != nil && input.XAgentsCore.ModelProvider != nil { - selection.Source = "session" + selection.Source = v1.ExecutionSourceSession } else if inherited != nil { - selection.Source = "agent" + selection.Source = v1.ExecutionSourceAgent } native := json.RawMessage(`{}`) if configuration.Agent.Core != nil { native = v1.ResolvedHarnessConfig(configuration.Agent.Core.HarnessConfig) } - nativeSource := input.harnessConfigSource - if nativeSource == "" { - nativeSource = "unknown" - } return v1.SessionExecutionConfiguration{ - HarnessConfig: v1.ExecutionHarnessConfigSelection{Value: native, Source: nativeSource}, + HarnessConfig: v1.ExecutionHarnessConfigSelection{Value: native, Source: input.harnessConfigSource}, Object: "agent.session.execution_configuration", SchemaVersion: 1, - Model: v1.ExecutionSelection{Value: &configuration.Agent.Model, Source: modelSource}, + Model: v1.ExecutionSelection{Value: &configuration.Agent.Model, Source: input.modelSource}, Harness: v1.ExecutionSelection{Value: &engine, Source: harnessSource}, ModelProvider: selection, } } diff --git a/services/core/internal/api/session_execution_configuration_test.go b/services/core/internal/api/session_execution_configuration_test.go index 88e4d85a4..fda9c4887 100644 --- a/services/core/internal/api/session_execution_configuration_test.go +++ b/services/core/internal/api/session_execution_configuration_test.go @@ -9,6 +9,7 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -16,19 +17,20 @@ func TestExecutionConfigurationSources(t *testing.T) { provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://saved.example/v1", APIKey: "secret-canary"} saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: "saved-model", XAgentsCore: &v1.SavedAgentCore{Harness: "codex", ModelProvider: provider.SafeView()}}} for _, tc := range []struct { - name, agent, extension string - saved *v1.SavedAgent - inherited, provider *v1.ModelProviderInput - modelSource, harnessSource, providerSource, status string + name, agent, extension string + saved *v1.SavedAgent + inherited, provider *v1.ModelProviderInput + modelSource, nativeSource, harnessSource, providerSource, status string }{ - {"saved", ``, ``, saved, provider, provider, "agent", "agent", "agent", "available"}, - {"model override", `,"agent":{"model":"override"}`, ``, saved, provider, provider, "session", "agent", "agent", "available"}, - {"harness override", `,"agent":{"x_agents_core":{"harness":"codex"}}`, ``, saved, provider, provider, "agent", "session", "agent", "available"}, - {"harness reset", `,"agent":{"x_agents_core":null}`, ``, saved, provider, provider, "agent", "deployment", "agent", "available"}, - {"explicit bundle", ``, `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://explicit.example/v1","api_key":"explicit-secret"}}`, saved, nil, provider, "agent", "agent", "session", "available"}, - {"provider null inherits", ``, `,"x_agents_core":{"model_provider":null}`, saved, provider, provider, "agent", "agent", "agent", "available"}, - {"inline deployment", `,"agent":{"model":"inline"}`, ``, nil, nil, provider, "session", "deployment", "deployment", "available"}, - {"inline explicit harness", `,"agent":{"model":"inline","x_agents_core":{"harness":"codex"}}`, ``, nil, nil, provider, "session", "session", "deployment", "available"}, + {"saved", ``, ``, saved, provider, provider, "agent", "agent", "agent", "agent", "available"}, + {"model override", `,"agent":{"model":"override"}`, ``, saved, provider, provider, "session", "session", "agent", "agent", "available"}, + {"harness override", `,"agent":{"x_agents_core":{"harness":"codex"}}`, ``, saved, provider, provider, "agent", "agent", "session", "agent", "available"}, + {"harness reset", `,"agent":{"x_agents_core":null}`, ``, saved, provider, provider, "agent", "agent", "deployment", "agent", "available"}, + {"explicit bundle", ``, `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://explicit.example/v1","api_key":"explicit-secret"}}`, saved, nil, provider, "agent", "session", "agent", "session", "available"}, + {"provider null inherits", ``, `,"x_agents_core":{"model_provider":null}`, saved, provider, provider, "agent", "agent", "agent", "agent", "available"}, + {"deployment defaults", ``, ``, nil, nil, provider, "deployment", "deployment", "deployment", "deployment", "available"}, + {"inline deployment", `,"agent":{"model":"inline"}`, ``, nil, nil, provider, "session", "session", "deployment", "deployment", "available"}, + {"inline explicit harness", `,"agent":{"model":"inline","x_agents_core":{"harness":"codex"}}`, ``, nil, nil, provider, "session", "session", "session", "deployment", "available"}, } { t.Run(tc.name, func(t *testing.T) { var decoded decodedSessionRequest @@ -39,8 +41,19 @@ func TestExecutionConfigurationSources(t *testing.T) { if err != nil { t.Fatal(err) } + deps, fakes := testDependencies(t) + fakes.modelProviders.resolve = func(context.Context, string) (*modelconfiguration.Snapshot, error) { + if tc.provider == nil { + return nil, nil + } + return &modelconfiguration.Snapshot{Provider: tc.provider, Model: "deployment-model"}, nil + } + h := &Handler{Dependencies: deps} + if err := h.prepareSessionModelConfiguration(t.Context(), &input, tc.saved, tc.inherited); err != nil { + t.Fatal(err) + } p := sessionExecutionProjection(input, tc.saved, tc.inherited, tc.provider, "codex", json.RawMessage(`{"agent":{"model":"resolved"}}`)) - if p.Model.Source != tc.modelSource || p.Harness.Source != tc.harnessSource || p.ModelProvider.Source != tc.providerSource || p.ModelProvider.Status != tc.status { + if string(p.Model.Source) != tc.modelSource || string(p.HarnessConfig.Source) != tc.nativeSource || string(p.Harness.Source) != tc.harnessSource || string(p.ModelProvider.Source) != tc.providerSource || string(p.ModelProvider.Status) != tc.status { t.Fatalf("wrong sources: %#v", p) } raw, _ := json.Marshal(p) diff --git a/services/core/internal/api/session_model_configuration.go b/services/core/internal/api/session_model_configuration.go index de8fa75a3..9f40e7c0b 100644 --- a/services/core/internal/api/session_model_configuration.go +++ b/services/core/internal/api/session_model_configuration.go @@ -61,9 +61,9 @@ func (h *Handler) prepareSessionModelConfiguration(ctx context.Context, input *s return &storedDataError{err} } } - input.modelSource = "session" + input.modelSource = v1.ExecutionSourceSession if !explicitModel && saved != nil { - input.modelSource = "agent" + input.modelSource = v1.ExecutionSourceAgent } if needsModel && input.deploymentDefaults != nil { if input.Agent == nil { @@ -74,26 +74,26 @@ func (h *Handler) prepareSessionModelConfiguration(ctx context.Context, input *s } model := input.deploymentDefaults.Model input.Agent.Model = &model - input.modelSource = "deployment" + input.modelSource = v1.ExecutionSourceDeployment } raw := json.RawMessage(`{}`) - source := "unknown" + source := v1.ExecutionSourceUnknown supplied := inline if len(session) > 0 { supplied = session } if len(supplied) > 0 { - raw, source = supplied, "session" + raw, source = supplied, v1.ExecutionSourceSession } else if explicitModel || explicitProvider { - source = "session" + source = v1.ExecutionSourceSession } else if saved != nil { - source = "agent" + source = v1.ExecutionSourceAgent // Changing the harness also discards the former adapter's parameters. if _, overridden := input.agentFields["x_agents_core"]; !overridden && saved.XAgentsCore != nil { raw = v1.ResolvedHarnessConfig(saved.XAgentsCore.HarnessConfig) } } else if input.deploymentDefaults != nil { - raw, source = v1.ResolvedHarnessConfig(input.deploymentDefaults.HarnessConfig), "deployment" + raw, source = v1.ResolvedHarnessConfig(input.deploymentDefaults.HarnessConfig), v1.ExecutionSourceDeployment } model := "" if input.Agent != nil && input.Agent.Model != nil { diff --git a/services/core/internal/api/session_model_configuration_test.go b/services/core/internal/api/session_model_configuration_test.go index 7d7558886..06988b899 100644 --- a/services/core/internal/api/session_model_configuration_test.go +++ b/services/core/internal/api/session_model_configuration_test.go @@ -59,7 +59,7 @@ func TestSessionNativeConfigurationSources(t *testing.T) { if input.Agent != nil && input.Agent.Model != nil { value = *input.Agent.Model } - if value != tc.model || string(input.resolvedHarnessConfig) != tc.native || input.harnessConfigSource != tc.source { + if value != tc.model || string(input.resolvedHarnessConfig) != tc.native || string(input.harnessConfigSource) != tc.source { t.Fatalf("model=%s native=%s source=%s", value, input.resolvedHarnessConfig, input.harnessConfigSource) } }) diff --git a/services/core/internal/api/session_model_defaults.go b/services/core/internal/api/session_model_defaults.go index 07ebf0398..033e67169 100644 --- a/services/core/internal/api/session_model_defaults.go +++ b/services/core/internal/api/session_model_defaults.go @@ -51,23 +51,23 @@ func modelProviderRequired(engine string) error { // resolveSessionExecution applies provider precedence: the Session bundle, the // saved Agent bundle, then the deployment default. Every Environment type // accepts every source, and every Session needs one. Bundles are never merged. -func (h *Handler) resolveSessionExecution(ctx context.Context, input sessionRequest, inherited *v1.ModelProviderInput, raw json.RawMessage) (string, *v1.ModelProviderInput, string, uuid.UUID, error) { +func (h *Handler) resolveSessionExecution(ctx context.Context, input sessionRequest, inherited *v1.ModelProviderInput, raw json.RawMessage) (string, *v1.ModelProviderInput, v1.ExecutionSource, uuid.UUID, error) { engine, err := h.sessionHarness(raw) if err != nil { return "", nil, "", uuid.Nil, err } var revision uuid.UUID - provider, source := inherited, v1.ModelProviderSourceAgent + provider, source := inherited, v1.ExecutionSourceAgent if extension := input.XAgentsCore; extension != nil { if extension.ModelProvider == nil && !input.modelProviderNull && len(extension.HarnessConfig) == 0 && len(extension.Environment) == 0 { return "", nil, "", uuid.Nil, errors.New("x_agents_core requires an execution option") } if extension.ModelProvider != nil { - provider, source = extension.ModelProvider, v1.ModelProviderSourceSession + provider, source = extension.ModelProvider, v1.ExecutionSourceSession } } if provider == nil && input.deploymentDefaults != nil { - provider, source, revision = input.deploymentDefaults.Provider, v1.ModelProviderSourceDeployment, input.deploymentDefaults.Revision + provider, source, revision = input.deploymentDefaults.Provider, v1.ExecutionSourceDeployment, input.deploymentDefaults.Revision } if provider == nil { return "", nil, "", uuid.Nil, modelProviderRequired(engine) diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index d8971677d..b4e091ee8 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -30,8 +30,8 @@ type sessionRequest struct { originalEnvironment json.RawMessage modelProviderNull bool deploymentDefaults *modelconfiguration.Snapshot - modelSource string - harnessConfigSource string + modelSource v1.ExecutionSource + harnessConfigSource v1.ExecutionSource resolvedHarnessConfig json.RawMessage v1.CreateSessionRequest Input json.RawMessage diff --git a/services/core/internal/api/turn_diagnostic_failure_test.go b/services/core/internal/api/turn_diagnostic_failure_test.go index 8fe650ca2..d5b3b20a7 100644 --- a/services/core/internal/api/turn_diagnostic_failure_test.go +++ b/services/core/internal/api/turn_diagnostic_failure_test.go @@ -3,9 +3,12 @@ package api import ( "encoding/json" "os" + "reflect" + "slices" "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) @@ -20,6 +23,7 @@ func TestDiagnosticFailureWhitelist(t *testing.T) { if err != nil { t.Fatal(err) } + produced := map[string]bool{} for want, inputs := range cases { if !strings.Contains(string(catalog), "`"+want+"`") { t.Fatal("uncatalogued diagnostics code", want) @@ -27,12 +31,59 @@ func TestDiagnosticFailureWhitelist(t *testing.T) { for _, input := range inputs { raw, _ := json.Marshal(map[string]string{"error_code": input, "error": "raw-secret-canary"}) got := turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: raw}) + produced[got.Code] = true encoded, _ := json.Marshal(got) if got.Code != want || strings.Contains(string(encoded), "canary") { t.Fatal(input, got) } } } + // Runtime classifications share the observation fixture; Core mappings above + // and Environment failures below exercise the actual diagnostic producers. + raw, err := os.ReadFile("../modelconfiguration/testdata/observation_cases.json") + if err != nil { + t.Fatal(err) + } + var observations []struct { + EngineErrorCode string `json:"engine_error_code"` + } + if err := json.Unmarshal(raw, &observations); err != nil { + t.Fatal(err) + } + for _, observation := range observations { + if code, _ := proto.NormalizeEngineFailure(observation.EngineErrorCode, nil); code != "" { + outcome, _ := json.Marshal(map[string]string{"error_code": "engine_failed", "engine_error_code": code}) + produced[turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: outcome}).Code] = true + } + } + for _, failure := range []string{"", "environment_unavailable", "runtime_preparation_failed", "model_provider_required", "unknown", "provisioning"} { + session := hostedFailureSession() + if failure != "provisioning" { + session.EnvironmentFailure = nil + session.EnvironmentInputActivity = &sessions.EnvironmentInputActivity{Status: "failed", Failure: failure} + } else { + session.EnvironmentFailure = &sessions.EnvironmentFailure{} + } + h, _, _ := adminTestHandler(t, serveDiagnostics(diagnosticSnapshotStore{session: session})) + w := diagnosticRequest(h, adminSessionsPath+session.ID+"/diagnostics", "Bearer admin") + var response SessionDiagnostics + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &response) != nil || response.Failure == nil { + t.Fatal(w.Code, w.Body) + } + produced[response.Failure.Code] = true + } + field, _ := reflect.TypeFor[DiagnosticFailure]().FieldByName("Code") + declared := strings.Split(field.Tag.Get("enums"), ",") + for code := range produced { + if !slices.Contains(declared, code) { + t.Errorf("diagnostic code %q missing from schema", code) + } + } + for _, code := range declared { + if !produced[code] { + t.Errorf("schema diagnostic code %q has no exercised producer", code) + } + } if got := turnDiagnosticFailure(sessions.Turn{Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed",`)}); got.Code != "internal_error" { t.Fatal("malformed outcome accepted", got) } diff --git a/services/core/internal/coremetrics/service.go b/services/core/internal/coremetrics/service.go index 84dfc5f91..8b2c497e0 100644 --- a/services/core/internal/coremetrics/service.go +++ b/services/core/internal/coremetrics/service.go @@ -55,14 +55,14 @@ var errPanicked = errors.New("periodic job pass panicked") // New reports the scheduler, which the Source reads live, and then jobs in // their order. Run runs the jobs. An enabled job needs a positive Every. func New(started time.Time, revision string, source Source, jobs ...Periodic) (*Service, error) { - s := &Service{source: source, started: started.UTC(), now: time.Now, jobIDs: []string{"scheduler"}, jobs: map[string]Job{"scheduler": {ID: "scheduler", Status: "unknown"}}, periodic: jobs} + s := &Service{source: source, started: started.UTC(), now: time.Now, jobIDs: []string{"scheduler"}, jobs: map[string]Job{"scheduler": {ID: "scheduler", Status: JobUnknown}}, periodic: jobs} if revisionPattern.MatchString(revision) { s.revision = &revision } for _, job := range jobs { - status := "unknown" + status := JobUnknown if job.Run == nil { - status = "stopped" + status = JobStopped } else if job.Every <= 0 { return nil, errors.New("coremetrics: periodic job " + job.ID + " needs a positive interval") } @@ -88,9 +88,9 @@ func (s *Service) RecordUnavailable() { s.refusals[i].count++ } func (s *Service) reportJob(id string, at time.Time, processed *int64, failed *int64, err error) { - status := "ok" + status := JobOk if err != nil || (failed != nil && *failed > 0) { - status = "failing" + status = JobFailing } s.mu.Lock() defer s.mu.Unlock() @@ -103,7 +103,7 @@ func (s *Service) stopJob(id string) { s.mu.Lock() defer s.mu.Unlock() if j, ok := s.jobs[id]; ok { - j.Status = "stopped" + j.Status = JobStopped s.jobs[id] = j } } @@ -196,12 +196,12 @@ func (s *Service) Read(ctx context.Context, name string) (View, error) { return View{}, err } live := s.source.Live() - view := View{Object: "core.metrics", Range: window, Service: ServiceState{Status: "running", Revision: s.revision, StartedAt: ptr(s.started), ExecutionOwner: live.ExecutionOwner}, + view := View{Object: "core.metrics", Range: window, Service: ServiceState{Status: ServiceRunning, Revision: s.revision, StartedAt: ptr(s.started), ExecutionOwner: live.ExecutionOwner}, Execution: Execution{SlotsInUse: live.SlotsInUse, SlotsTotal: live.SlotsTotal, ConnectedDaemons: live.ConnectedDaemons}, Database: Database{Pool: live.Pool}, Jobs: make([]Job, 0, len(s.jobIDs))} s.mu.Lock() latest := s.latest if latest.At.IsZero() || now.Sub(latest.At) > 2*SampleInterval || !latest.Healthy { - view.Service.Status = "degraded" + view.Service.Status = ServiceDegraded } if !latest.At.IsZero() && now.Sub(latest.At) <= 2*SampleInterval { view.Execution.QueuedTurns, view.Execution.WaitingForDaemon, view.Execution.InProgressTurns = latest.Queued, latest.WaitingForDaemon, latest.InProgress @@ -223,7 +223,7 @@ func (s *Service) Read(ctx context.Context, name string) (View, error) { defer cancel() history, err := s.source.History(query, window.Start, window.End, time.Duration(window.ResolutionSeconds)*time.Second) if err != nil { - view.Service.Status = "degraded" + view.Service.Status = ServiceDegraded } else { view.Execution.Interrupted = ptr(history.Interrupted) view.Execution.QueueWaitMS = history.QueueWaitMS @@ -232,11 +232,11 @@ func (s *Service) Read(ctx context.Context, name string) (View, error) { } } if live.ExecutionOwner == nil || !*live.ExecutionOwner { - view.Service.Status = "degraded" + view.Service.Status = ServiceDegraded } for _, job := range view.Jobs { - if job.Status == "failing" { - view.Service.Status = "degraded" + if job.Status == JobFailing { + view.Service.Status = ServiceDegraded } } var memory runtime.MemStats diff --git a/services/core/internal/coremetrics/types.go b/services/core/internal/coremetrics/types.go index 611efe657..ca97cce30 100644 --- a/services/core/internal/coremetrics/types.go +++ b/services/core/internal/coremetrics/types.go @@ -12,6 +12,22 @@ import ( // resolution. var ErrInvalidRange = errors.New("invalid Core metrics range") +type JobStatus string + +const ( + JobOk JobStatus = "ok" + JobFailing JobStatus = "failing" + JobStopped JobStatus = "stopped" + JobUnknown JobStatus = "unknown" +) + +type ServiceStatus string + +const ( + ServiceRunning ServiceStatus = "running" + ServiceDegraded ServiceStatus = "degraded" +) + type Latency struct { P50 *float64 `json:"p50" extensions:"x-nullable" binding:"required"` P95 *float64 `json:"p95" extensions:"x-nullable" binding:"required"` @@ -22,10 +38,10 @@ type Range struct { ResolutionSeconds int64 `json:"resolution_seconds" binding:"required"` } type ServiceState struct { - Status string `json:"status" enums:"running,degraded" binding:"required"` - Revision *string `json:"revision" extensions:"x-nullable" binding:"required"` - StartedAt *time.Time `json:"started_at" extensions:"x-nullable" binding:"required"` - ExecutionOwner *bool `json:"execution_owner" extensions:"x-nullable" binding:"required"` + Status ServiceStatus `json:"status" binding:"required"` + Revision *string `json:"revision" extensions:"x-nullable" binding:"required"` + StartedAt *time.Time `json:"started_at" extensions:"x-nullable" binding:"required"` + ExecutionOwner *bool `json:"execution_owner" extensions:"x-nullable" binding:"required"` } type ExecutionBucket struct { Start time.Time `json:"start" binding:"required"` @@ -64,7 +80,7 @@ type Database struct { } type Job struct { ID string `json:"id" binding:"required"` - Status string `json:"status" enums:"ok,failing,stopped,unknown" binding:"required"` + Status JobStatus `json:"status" binding:"required"` LastRunAt *time.Time `json:"last_run_at" extensions:"x-nullable" binding:"required"` Processed *int64 `json:"processed" extensions:"x-nullable" binding:"required"` Failed *int64 `json:"failed" extensions:"x-nullable" binding:"required"` diff --git a/services/core/internal/deployment/allocations.go b/services/core/internal/deployment/allocations.go index d825b257b..48fda2384 100644 --- a/services/core/internal/deployment/allocations.go +++ b/services/core/internal/deployment/allocations.go @@ -8,11 +8,10 @@ import ( "fmt" "time" - "github.com/google/uuid" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" ) // ReserveAllocation commits the allocation of the tenant's hosted Environment @@ -70,7 +69,7 @@ func (e *ExecutionOperations) ReserveAllocation(ctx context.Context, key Allocat return ErrInvalidInput } allocation := NewAllocation{ID: uuid.NewString(), EnvironmentID: environment.ID, ProviderKey: installation, Generation: d.Generation, ServeCredentialHash: hex.EncodeToString(serve)} - if d.Mode == "nodes" { + if d.Mode == string(sandbox.DeploymentNodes) { reserved, err := tx.LoadReserved() if err != nil { return err @@ -342,7 +341,7 @@ func (s *Service) LifecycleNode(ctx context.Context, tenant, environment string) if err != nil { return "", err } - if p.Provider == "" || p.Mode == "direct" { + if p.Provider == "" || p.Mode == string(sandbox.DeploymentDirect) { if p.PlacementNodeID != "" || p.AllocationNodeID != "" { return "", placement.ErrNodeUnavailable } diff --git a/services/core/internal/deployment/execution.go b/services/core/internal/deployment/execution.go index ee57e97be..6af321418 100644 --- a/services/core/internal/deployment/execution.go +++ b/services/core/internal/deployment/execution.go @@ -272,7 +272,7 @@ func (e *ExecutionOperations) saveSelection(tx DeploymentTx, d Record, input san if err != nil { return err } - return tx.SaveSelection(SelectionRecord{InstallationID: d.InstallationID, Provider: input.Provider, BackendFingerprint: description.BackendFingerprint, Mode: description.Mode, + return tx.SaveSelection(SelectionRecord{InstallationID: d.InstallationID, Provider: input.Provider, BackendFingerprint: description.BackendFingerprint, Mode: string(description.Mode), Generation: d.Generation + 1, Specification: specification, Configuration: sandbox.ConfigurationRecord{Public: configurationJSON(record.Public), Metadata: configurationJSON(record.Metadata), Secret: record.Secret}}) } diff --git a/services/core/internal/deployment/health.go b/services/core/internal/deployment/health.go index e8e37ffca..3b5c9492b 100644 --- a/services/core/internal/deployment/health.go +++ b/services/core/internal/deployment/health.go @@ -12,7 +12,7 @@ import ( // unreadiness only: unknown values, including arbitrary text, become // provider_unavailable, a ready provider has none and empty stays empty. func normalizeHealth(health NodeHealth) (NodeHealth, error) { - health.Diagnostic = sandbox.NormalizeNodeDiagnostic(health.Diagnostic) + health.Diagnostic = sandbox.NodeDiagnosticCode(sandbox.NormalizeNodeDiagnostic(string(health.Diagnostic))) if health.ProviderReady { health.Diagnostic = "" } @@ -75,20 +75,20 @@ func historyPoints(window coremetrics.Range, samples []HostHistoryPoint) []HostH // nodeRollout reports a node's preparation of the target generation. func nodeRollout(n NodeRecord) NodeRollout { - out := NodeRollout{State: "unknown", ReadyGeneration: n.ReadyGeneration} + out := NodeRollout{State: NodeRolloutUnknown, ReadyGeneration: n.ReadyGeneration} if !n.Online { return out } if n.ProtocolVersion == 1 && n.DeploymentGeneration != n.TargetGeneration { - out.State = "update_required" + out.State = NodeRolloutUpdateRequired return out } - switch n.TargetState { - case "ready", "preparing", "failed": - out.State = n.TargetState + switch NodeRolloutState(n.TargetState) { + case NodeRolloutReady, NodeRolloutPreparing, NodeRolloutFailed: + out.State = NodeRolloutState(n.TargetState) } - if out.State == "failed" && n.TargetDiagnostic != "" { - out.Diagnostic = sandbox.NormalizeNodeDiagnostic(n.TargetDiagnostic) + if out.State == NodeRolloutFailed && n.TargetDiagnostic != "" { + out.Diagnostic = sandbox.NodeDiagnosticCode(sandbox.NormalizeNodeDiagnostic(n.TargetDiagnostic)) } return out } diff --git a/services/core/internal/deployment/node.go b/services/core/internal/deployment/node.go index 164d54234..0bd2e3b31 100644 --- a/services/core/internal/deployment/node.go +++ b/services/core/internal/deployment/node.go @@ -46,11 +46,11 @@ type Enrollment struct { type NodeHealth struct { Host *NodeHost `json:"-"` // Fixed reason for the last reported unreadiness; absent while the provider is ready. Clients treat an unknown value as provider_unavailable. - Diagnostic string `json:"diagnostic,omitempty" enums:"provider_unavailable,host_unsupported,artifacts_unavailable,runtime_download_failed,runtime_image_unavailable,capacity_insufficient"` - ProviderReady bool `json:"provider_ready" binding:"required"` - CPUCount *int64 `json:"cpu_count" extensions:"x-nullable" binding:"required"` - AvailableMemoryBytes *int64 `json:"available_memory_bytes" extensions:"x-nullable" binding:"required"` - AvailableDiskBytes *int64 `json:"available_disk_bytes" extensions:"x-nullable" binding:"required"` + Diagnostic sandbox.NodeDiagnosticCode `json:"diagnostic,omitempty"` + ProviderReady bool `json:"provider_ready" binding:"required"` + CPUCount *int64 `json:"cpu_count" extensions:"x-nullable" binding:"required"` + AvailableMemoryBytes *int64 `json:"available_memory_bytes" extensions:"x-nullable" binding:"required"` + AvailableDiskBytes *int64 `json:"available_disk_bytes" extensions:"x-nullable" binding:"required"` } type Node struct { diff --git a/services/core/internal/deployment/nodes.go b/services/core/internal/deployment/nodes.go index 91867c15f..f166a12b8 100644 --- a/services/core/internal/deployment/nodes.go +++ b/services/core/internal/deployment/nodes.go @@ -162,7 +162,7 @@ func (s *Service) CreateEnrollment(ctx context.Context, capacity Capacity) (Enro if d.Reset != nil { return ErrResetInProgress } - if d.Mode != "nodes" { + if d.Mode != string(sandbox.DeploymentNodes) { return ErrConflict } if _, err := s.specification(d); err != nil { @@ -211,7 +211,7 @@ func (s *Service) Enroll(ctx context.Context, token string, input Enrollment) (N if d.Reset != nil { return ErrResetInProgress } - if d.Mode != "nodes" || input.Provider != d.Provider { + if d.Mode != string(sandbox.DeploymentNodes) || input.Provider != d.Provider { return ErrInvalidInput } spec, err := s.specification(d) @@ -278,7 +278,7 @@ func (s *Service) AuthenticateNode(ctx context.Context, nodeID, credential strin if err != nil { return placement.ErrNodeUnavailable } - if n.InstallationID != d.InstallationID || d.Mode != "nodes" { + if n.InstallationID != d.InstallationID || d.Mode != string(sandbox.DeploymentNodes) { return ErrNodeCredential } if err := s.checkEnrollmentIdentity(tx, d, n); err != nil { @@ -397,7 +397,7 @@ func (s *Service) NodeConfiguration(ctx context.Context, nodeID, token string, g if node == nil && d.Reset != nil { return ErrResetInProgress } - if d.Mode != "nodes" { + if d.Mode != string(sandbox.DeploymentNodes) { return ErrConflict } if node != nil { @@ -449,7 +449,7 @@ func (s *Service) connection(tx NodeReads, d Record, nodeID, connectionID string if err != nil { return StoredNode{}, err } - if n.ConnectionID != connectionID || n.ConnectedEpoch != epoch || epoch == 0 || epoch > math.MaxInt64 || d.OwnerEpoch != epoch || n.InstallationID != d.InstallationID || d.Mode != "nodes" { + if n.ConnectionID != connectionID || n.ConnectedEpoch != epoch || epoch == 0 || epoch > math.MaxInt64 || d.OwnerEpoch != epoch || n.InstallationID != d.InstallationID || d.Mode != string(sandbox.DeploymentNodes) { return StoredNode{}, ErrNodeCredential } return n, nil @@ -563,7 +563,7 @@ func (s *Service) heartbeat(ctx context.Context, nodeID, connectionID string, ep if health.ProviderReady { state = "ready" } - statuses = []sandbox.GenerationStatus{{Generation: n.DeploymentGeneration, SpecificationDigest: n.SpecificationDigest, State: state, Diagnostic: health.Diagnostic}} + statuses = []sandbox.GenerationStatus{{Generation: n.DeploymentGeneration, SpecificationDigest: n.SpecificationDigest, State: state, Diagnostic: string(health.Diagnostic)}} } return s.recordGenerations(tx, d, n, statuses, protocol) }) diff --git a/services/core/internal/deployment/placement/placement.go b/services/core/internal/deployment/placement/placement.go index 3a4d68e1d..c7d22698f 100644 --- a/services/core/internal/deployment/placement/placement.go +++ b/services/core/internal/deployment/placement/placement.go @@ -164,7 +164,7 @@ func (r *Rules) DecidePlacement(d Deployment, nodes []Node) (*Placement, error) if err := r.CheckPublicOrigin(); err != nil { return nil, err } - if d.Mode == "direct" { + if d.Mode == string(sandbox.DeploymentDirect) { return nil, nil } var chosen *Node diff --git a/services/core/internal/deployment/reset.go b/services/core/internal/deployment/reset.go index a404549b1..90da87734 100644 --- a/services/core/internal/deployment/reset.go +++ b/services/core/internal/deployment/reset.go @@ -8,11 +8,13 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" ) +type ResetMode string + // ResetRequest starts or escalates a reset of the sandbox deployment. type ResetRequest struct { - ExpectedGeneration uint64 `json:"expected_generation" binding:"required" minimum:"0"` - Clear string `json:"clear" binding:"required" enums:"auto,force"` - DeadlineSeconds *int32 `json:"deadline_seconds,omitempty" minimum:"300" maximum:"86400"` + ExpectedGeneration uint64 `json:"expected_generation" binding:"required" minimum:"0"` + Clear ResetMode `json:"clear" binding:"required"` + DeadlineSeconds *int32 `json:"deadline_seconds,omitempty" minimum:"300" maximum:"86400"` } // ResetSession is a hosted Session a reset still has to archive. @@ -21,9 +23,9 @@ type ResetSession struct{ SessionID, TenantID string } const ( // ResetAuto archives idle Sessions and escalates to ResetForce at the // deadline. - ResetAuto = "auto" + ResetAuto ResetMode = "auto" // ResetForce archives every hosted Session, busy ones included. - ResetForce = "force" + ResetForce ResetMode = "force" defaultResetDeadlineSeconds = 3600 ) @@ -72,7 +74,7 @@ func (e *ExecutionOperations) StartReset(ctx context.Context, installation strin return ErrNotConfigured } if d.Reset != nil { - if d.Reset.Clear == input.Clear { + if d.Reset.Clear == string(input.Clear) { return nil } if input.Clear != ResetForce { @@ -87,7 +89,7 @@ func (e *ExecutionOperations) StartReset(ctx context.Context, installation strin if !ok { return ErrInvalidInput } - if err := tx.StartReset(input.Clear, deadline, source); err != nil { + if err := tx.StartReset(string(input.Clear), deadline, source); err != nil { return err } return tx.RecordAudit("reset_start", installation) @@ -126,7 +128,7 @@ func (e *ExecutionOperations) AdvanceResetDeadline(ctx context.Context) error { if err != nil { return err } - if d.Reset == nil || d.Reset.Clear != ResetAuto || d.Reset.DeadlineAt == nil || time.Now().Before(*d.Reset.DeadlineAt) { + if d.Reset == nil || d.Reset.Clear != string(ResetAuto) || d.Reset.DeadlineAt == nil || time.Now().Before(*d.Reset.DeadlineAt) { return nil } source, err := tx.LoadResetSource() diff --git a/services/core/internal/deployment/reset_test.go b/services/core/internal/deployment/reset_test.go index 1a2c1187a..c7477b00c 100644 --- a/services/core/internal/deployment/reset_test.go +++ b/services/core/internal/deployment/reset_test.go @@ -9,9 +9,8 @@ import ( "testing" "time" - "github.com/google/uuid" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/google/uuid" ) // resetTx serves stored and records each reset write, with its arguments. @@ -37,9 +36,9 @@ func resetTx(t *testing.T, stored Record, resources Resources, source adminaudit } } -func resetting(t *testing.T, installation, clear string, requestedAt time.Time, deadline *time.Time) Record { +func resetting(t *testing.T, installation string, clear ResetMode, requestedAt time.Time, deadline *time.Time) Record { d := webDeployment(t, installation, "docker", 4) - d.Reset = &ResetState{Clear: clear, RequestedAt: requestedAt, DeadlineAt: deadline} + d.Reset = &ResetState{Clear: string(clear), RequestedAt: requestedAt, DeadlineAt: deadline} return d } diff --git a/services/core/internal/deployment/rules_test.go b/services/core/internal/deployment/rules_test.go index 896c6eeed..fcb56a53e 100644 --- a/services/core/internal/deployment/rules_test.go +++ b/services/core/internal/deployment/rules_test.go @@ -200,7 +200,7 @@ func TestNormalizeHealth(t *testing.T) { for _, c := range []struct { name string in NodeHealth - diagnostic string + diagnostic sandbox.NodeDiagnosticCode invalid bool }{ {"ready clears the diagnostic", NodeHealth{ProviderReady: true, Diagnostic: sandbox.NodeProviderUnavailable}, "", false}, @@ -282,8 +282,8 @@ func TestNodeRollout(t *testing.T) { for _, c := range []struct { name string n NodeRecord - state string - diagnostic string + state NodeRolloutState + diagnostic sandbox.NodeDiagnosticCode }{ {"offline", NodeRecord{TargetState: "ready", ReadyGeneration: &ready}, "unknown", ""}, {"protocol 1 on another generation", NodeRecord{Online: true, ProtocolVersion: 1, DeploymentGeneration: 1, TargetGeneration: 2, TargetState: "ready", ReadyGeneration: &ready}, "update_required", ""}, diff --git a/services/core/internal/deployment/service.go b/services/core/internal/deployment/service.go index ae96ff02f..83e1b64b1 100644 --- a/services/core/internal/deployment/service.go +++ b/services/core/internal/deployment/service.go @@ -44,7 +44,7 @@ func (s *Service) View(ctx context.Context) (View, error) { // view reports the public URL as the deployment's read-only core_url. func (s *Service) view(snapshot Snapshot) (View, error) { d := snapshot.Record - result := View{InstallationID: d.InstallationID, Provider: d.Provider, CoreURL: s.rules.PublicURL(), OwnerEpoch: d.OwnerEpoch, Generation: d.Generation, Mode: d.Mode, Rollout: snapshot.Rollout, Resources: snapshot.Resources} + result := View{InstallationID: d.InstallationID, Provider: d.Provider, CoreURL: s.rules.PublicURL(), OwnerEpoch: d.OwnerEpoch, Generation: d.Generation, Mode: sandbox.DeploymentMode(d.Mode), Rollout: snapshot.Rollout, Resources: snapshot.Resources} if len(d.Specification) > 0 && string(d.Specification) != "{}" { var spec sandbox.DeploymentSpec if json.Unmarshal(d.Specification, &spec) == nil { @@ -69,7 +69,7 @@ func (s *Service) view(snapshot Snapshot) (View, error) { } } if d.Reset != nil { - result.Reset = &Reset{Clear: d.Reset.Clear, RequestedAt: d.Reset.RequestedAt, DeadlineAt: d.Reset.DeadlineAt, ForcedAt: d.Reset.ForcedAt, Remaining: snapshot.Remaining} + result.Reset = &Reset{Clear: ResetMode(d.Reset.Clear), RequestedAt: d.Reset.RequestedAt, DeadlineAt: d.Reset.DeadlineAt, ForcedAt: d.Reset.ForcedAt, Remaining: snapshot.Remaining} } return result, nil } @@ -204,7 +204,7 @@ func (s *Service) GenerationPage(ctx context.Context, after int64) ([]Setup, err if err != nil { return nil, err } - v.Mode, v.Operations = adapter.Mode, adapter.Operations() + v.Mode, v.Operations = string(adapter.Mode), adapter.Operations() result = append(result, v) } return result, nil @@ -244,7 +244,7 @@ func (s *Service) SetupForSelection(installationID string, input sandbox.Selecti if err := s.rules.CheckPublicOrigin(); err != nil { return Setup{}, err } - result := Setup{InstallationID: installationID, Provider: input.Provider, Mode: description.Mode, Specification: normalized.DeploymentSpec, Configuration: normalized.Configuration, BackendFingerprint: description.BackendFingerprint} + result := Setup{InstallationID: installationID, Provider: input.Provider, Mode: string(description.Mode), Specification: normalized.DeploymentSpec, Configuration: normalized.Configuration, BackendFingerprint: description.BackendFingerprint} return s.describe(result) } diff --git a/services/core/internal/deployment/session_archive.go b/services/core/internal/deployment/session_archive.go index eb33b012d..0f6c98ad4 100644 --- a/services/core/internal/deployment/session_archive.go +++ b/services/core/internal/deployment/session_archive.go @@ -57,7 +57,7 @@ func (e *ExecutionOperations) archiveSession(ctx context.Context, tenantID, sess if err := checkArchiveReset(d, *resetRequestedAt); err != nil { return err } - if d.Reset.Clear == ResetAuto { + if d.Reset.Clear == string(ResetAuto) { busy, err := tx.LoadResetBusy() if err != nil { return err diff --git a/services/core/internal/deployment/session_archive_test.go b/services/core/internal/deployment/session_archive_test.go index 49bb493a4..8a5ca4049 100644 --- a/services/core/internal/deployment/session_archive_test.go +++ b/services/core/internal/deployment/session_archive_test.go @@ -51,9 +51,9 @@ func TestCheckArchiveReset(t *testing.T) { reset *ResetState want error }{ - "running reset": {&ResetState{Clear: ResetAuto, RequestedAt: requested}, nil}, + "running reset": {&ResetState{Clear: string(ResetAuto), RequestedAt: requested}, nil}, "no reset": {nil, ErrConflict}, - "another request": {&ResetState{Clear: ResetAuto, RequestedAt: requested.Add(time.Second)}, ErrConflict}, + "another request": {&ResetState{Clear: string(ResetAuto), RequestedAt: requested.Add(time.Second)}, ErrConflict}, } { if err := checkArchiveReset(Record{Reset: test.reset}, requested); !errors.Is(err, test.want) { t.Errorf("%s: got %v, want %v", name, err, test.want) @@ -279,8 +279,8 @@ func TestArchiveSession(t *testing.T) { func TestArchiveResetSession(t *testing.T) { requested := time.Unix(100, 0) - resetting := func(clear string) Record { - return Record{InstallationID: "installation", Provider: "docker", Generation: 1, Reset: &ResetState{Clear: clear, RequestedAt: requested}} + resetting := func(clear ResetMode) Record { + return Record{InstallationID: "installation", Provider: "docker", Generation: 1, Reset: &ResetState{Clear: string(clear), RequestedAt: requested}} } hosted := &sessions.Environment{ID: "environment", Status: "connected", Configuration: json.RawMessage(`{"type":"openai_hosted"}`)} failed := &sessions.Environment{ID: "environment", Status: "failed", Configuration: hosted.Configuration} diff --git a/services/core/internal/deployment/view.go b/services/core/internal/deployment/view.go index f45b90afe..23bef5045 100644 --- a/services/core/internal/deployment/view.go +++ b/services/core/internal/deployment/view.go @@ -7,13 +7,30 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) +type NodeRolloutState string + +const ( + NodeRolloutReady NodeRolloutState = "ready" + NodeRolloutPreparing NodeRolloutState = "preparing" + NodeRolloutFailed NodeRolloutState = "failed" + NodeRolloutUpdateRequired NodeRolloutState = "update_required" + NodeRolloutUnknown NodeRolloutState = "unknown" +) + +type RolloutState string + +const ( + RolloutSettled RolloutState = "settled" + RolloutPreparing RolloutState = "preparing" +) + // View is the sandbox deployment as administrators read it. type View struct { Rollout Rollout `json:"rollout" binding:"required"` Specification *sandbox.DeploymentSpec `json:"specification,omitempty"` SpecificationDigest string `json:"specification_digest,omitempty"` Generation uint64 `json:"generation" binding:"required"` - Mode string `json:"mode" enums:",nodes,direct" binding:"required"` + Mode sandbox.DeploymentMode `json:"mode" binding:"required"` Resources Resources `json:"resources" binding:"required"` Configuration json.RawMessage `json:"configuration,omitempty" swaggertype:"object"` Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` @@ -43,10 +60,10 @@ type Suspension struct { type NodeRollout struct { // Target preparation, independent of an old pin's serving readiness. - State string `json:"state" enums:"ready,preparing,failed,update_required,unknown" binding:"required"` + State NodeRolloutState `json:"state" binding:"required"` // Durable serving-generation pin; online and provider_ready still gate placement. - ReadyGeneration *uint64 `json:"ready_generation" extensions:"x-nullable" binding:"required"` - Diagnostic string `json:"diagnostic,omitempty" enums:"provider_unavailable,host_unsupported,artifacts_unavailable,runtime_download_failed,runtime_image_unavailable,capacity_insufficient"` + ReadyGeneration *uint64 `json:"ready_generation" extensions:"x-nullable" binding:"required"` + Diagnostic sandbox.NodeDiagnosticCode `json:"diagnostic,omitempty"` } type RolloutNodes struct { @@ -58,14 +75,14 @@ type RolloutNodes struct { } type Rollout struct { - State string `json:"state" enums:"settled,preparing" binding:"required"` + State RolloutState `json:"state" binding:"required"` PreviousGenerationSandboxes int64 `json:"previous_generation_sandboxes" binding:"required"` Nodes *RolloutNodes `json:"nodes" extensions:"x-nullable" binding:"required"` } // Reset contains only durable state and a single-snapshot resource partition. type Reset struct { - Clear string `json:"clear" enums:"auto,force" binding:"required"` + Clear ResetMode `json:"clear" binding:"required"` RequestedAt time.Time `json:"requested_at" binding:"required"` DeadlineAt *time.Time `json:"deadline_at" extensions:"x-nullable" binding:"required"` ForcedAt *time.Time `json:"forced_at" extensions:"x-nullable" binding:"required"` diff --git a/services/core/internal/execution/deployment_provider_observations_test.go b/services/core/internal/execution/deployment_provider_observations_test.go index 9d769cf31..b1240173f 100644 --- a/services/core/internal/execution/deployment_provider_observations_test.go +++ b/services/core/internal/execution/deployment_provider_observations_test.go @@ -88,7 +88,7 @@ func (f finishObservationFixture) start(t *testing.T) sessions.InputReceipt { } return receipt } -func (f finishObservationFixture) fields(t *testing.T) (*time.Time, *string) { +func (f finishObservationFixture) fields(t *testing.T) (*time.Time, *modelconfiguration.ProviderErrorCode) { t.Helper() rows, err := f.defaults.List(t.Context()) if err != nil || len(rows) != 1 { diff --git a/services/core/internal/execution/model_execution_test.go b/services/core/internal/execution/model_execution_test.go index 09435c33f..d598d135f 100644 --- a/services/core/internal/execution/model_execution_test.go +++ b/services/core/internal/execution/model_execution_test.go @@ -6,13 +6,12 @@ import ( "strings" "testing" - "github.com/google/uuid" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" ) // frozenProvider reads, for every Session, the bundle a Session of engine @@ -42,7 +41,7 @@ func TestSessionModelProviderPreservesUpstreamBundleForEveryHarness(t *testing.T for _, engine := range []string{"codex", "claude_sdk", "mcode"} { for _, protocol := range []string{"anthropic", "responses", "chat_completions"} { t.Run(engine+"/"+protocol, func(t *testing.T) { - provider := &v1.ModelProviderInput{Protocol: protocol, BaseURL: "https://example.com", APIKey: "private-key", ContextWindow: 200000, MaxOutputTokens: 8000} + provider := &v1.ModelProviderInput{Protocol: modelprovider.Protocol(protocol), BaseURL: "https://example.com", APIKey: "private-key", ContextWindow: 200000, MaxOutputTokens: 8000} got, err := resolvedSessionModelProvider(provider, engine) native := engine == "mcode" || engine == "codex" && protocol == "responses" || engine == "claude_sdk" && protocol == "anthropic" if !native { @@ -50,7 +49,7 @@ func TestSessionModelProviderPreservesUpstreamBundleForEveryHarness(t *testing.T if got != nil || !errors.As(err, &protocolError) || strings.Contains(err.Error(), provider.APIKey) { t.Fatal("non-native provider was not safely rejected") } - if provider.Protocol != protocol { + if string(provider.Protocol) != protocol { t.Fatal("rejection rewrote the frozen provider protocol") } return diff --git a/services/core/internal/execution/runtime_lifecycle.go b/services/core/internal/execution/runtime_lifecycle.go index 290510a1c..7eb31a8cd 100644 --- a/services/core/internal/execution/runtime_lifecycle.go +++ b/services/core/internal/execution/runtime_lifecycle.go @@ -8,8 +8,6 @@ import ( "sync" "time" - "github.com/google/uuid" - "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxbootstrap" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink/relay" @@ -19,6 +17,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" ) // RuntimeProvider binds one deployment to one sandbox installation. @@ -82,10 +81,10 @@ func validatedRuntimeProvider(config *RuntimeProvider, registry *runtimegateway. return RuntimeProvider{}, err } copied := *config - if copied.ProviderKind == "" || (copied.Mode != "nodes" && copied.Mode != "direct") { + if copied.ProviderKind == "" || (copied.Mode != string(sandbox.DeploymentNodes) && copied.Mode != string(sandbox.DeploymentDirect)) { return RuntimeProvider{}, sandbox.ErrInvalid } - if copied.Mode == "direct" && copied.Suspension != nil { + if copied.Mode == string(sandbox.DeploymentDirect) && copied.Suspension != nil { return RuntimeProvider{}, sandbox.ErrInvalid } if config.Suspension != nil { diff --git a/services/core/internal/execution/runtime_manager.go b/services/core/internal/execution/runtime_manager.go index b22aed845..63c865fe4 100644 --- a/services/core/internal/execution/runtime_manager.go +++ b/services/core/internal/execution/runtime_manager.go @@ -88,7 +88,7 @@ func (m *runtimeManager) node(id string) (*runtimeNode, error) { m.mu.Unlock() return nil, errRuntimeTransition } - if m.closed || m.config.Provider == nil || (id == "") != (m.config.Mode == "direct") { + if m.closed || m.config.Provider == nil || (id == "") != (m.config.Mode == string(sandbox.DeploymentDirect)) { m.mu.Unlock() return nil, ErrExecutionUnavailable } diff --git a/services/core/internal/execution/sandbox_configuration.go b/services/core/internal/execution/sandbox_configuration.go index 916271e37..be0fb6651 100644 --- a/services/core/internal/execution/sandbox_configuration.go +++ b/services/core/internal/execution/sandbox_configuration.go @@ -159,7 +159,7 @@ func (w *Worker) ObservationSource(ctx context.Context) (runtimeobs.Source, stri // provider builds the setup's provider. The setup carries the mode and // declared operations that deployment read from the provider's registration. func (s *runtimeManager) provider(setup deployment.Setup) (sandbox.SandboxProvider, error) { - if setup.Mode == "nodes" { + if setup.Mode == string(sandbox.DeploymentNodes) { if s.nodeProviders == nil { return nil, errors.New("sandbox node transport is unavailable") } diff --git a/services/core/internal/execution/sandbox_deployment_switch.go b/services/core/internal/execution/sandbox_deployment_switch.go index 6ed80bf45..14534907f 100644 --- a/services/core/internal/execution/sandbox_deployment_switch.go +++ b/services/core/internal/execution/sandbox_deployment_switch.go @@ -115,7 +115,7 @@ func (m *runtimeManager) activateDeployment(ctx context.Context, expected deploy m.publishEmptyDeployment(expected.InstallationID, expected.Generation) return nil } - if config == nil || config.InstallationID != expected.InstallationID || config.Generation != expected.Generation || config.Mode != expected.Mode || config.ProviderKind != expected.Provider { + if config == nil || config.InstallationID != expected.InstallationID || config.Generation != expected.Generation || config.Mode != string(expected.Mode) || config.ProviderKind != expected.Provider { return sandbox.ErrInvalid } copied, err := validatedRuntimeProvider(config, m.registry) diff --git a/services/core/internal/execution/worker_metrics.go b/services/core/internal/execution/worker_metrics.go index c5833dfa1..cd486579c 100644 --- a/services/core/internal/execution/worker_metrics.go +++ b/services/core/internal/execution/worker_metrics.go @@ -4,6 +4,8 @@ import ( "errors" "sync" "time" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/coremetrics" ) // WorkerMetrics contains only observations from this worker's existing @@ -18,7 +20,7 @@ type WorkerMetrics struct { // WorkerJobMetrics describes the last completed scheduling poll. Failed counts // failed polls, not failed Turns; Processed is unknown when a poll fails. type WorkerJobMetrics struct { - Status string + Status coremetrics.JobStatus LastRunAt *time.Time Processed *int64 Failed *int64 @@ -41,7 +43,7 @@ func (w *Worker) MetricsSnapshot() WorkerMetrics { value.Scheduler.Processed = copyMetric(value.Scheduler.Processed) value.Scheduler.Failed = copyMetric(value.Scheduler.Failed) if value.Scheduler.Status == "" { - value.Scheduler.Status = "unknown" + value.Scheduler.Status = coremetrics.JobUnknown } return value } @@ -75,10 +77,10 @@ func (w *Worker) observeOwnership(err error) { func (w *Worker) observeSchedulerPoll(processed int, err error) { now, handled, failed := time.Now().UTC(), int64(processed), int64(0) - job := WorkerJobMetrics{Status: "ok", LastRunAt: &now, Processed: &handled, Failed: &failed} + job := WorkerJobMetrics{Status: coremetrics.JobOk, LastRunAt: &now, Processed: &handled, Failed: &failed} if err != nil { failed = 1 - job.Status, job.Processed = "failing", nil + job.Status, job.Processed = coremetrics.JobFailing, nil } w.metrics.mu.Lock() defer w.metrics.mu.Unlock() @@ -88,9 +90,9 @@ func (w *Worker) observeSchedulerPoll(processed int, err error) { func (w *Worker) observeWorkerStop(runErr, contextErr error) { w.metrics.mu.Lock() defer w.metrics.mu.Unlock() - w.metrics.value.Scheduler.Status = "stopped" + w.metrics.value.Scheduler.Status = coremetrics.JobStopped if runErr != nil && (contextErr == nil || !errors.Is(runErr, contextErr)) { - w.metrics.value.Scheduler.Status = "failing" + w.metrics.value.Scheduler.Status = coremetrics.JobFailing } } diff --git a/services/core/internal/modelconfiguration/configuration.go b/services/core/internal/modelconfiguration/configuration.go index f4b9e776e..f8e03f044 100644 --- a/services/core/internal/modelconfiguration/configuration.go +++ b/services/core/internal/modelconfiguration/configuration.go @@ -19,7 +19,7 @@ type Configuration struct { HarnessConfig json.RawMessage UpdatedAt time.Time LastUsedAt *time.Time - LastErrorCode *string + LastErrorCode *ProviderErrorCode LastErrorAt *time.Time } diff --git a/services/core/internal/modelconfiguration/observation.go b/services/core/internal/modelconfiguration/observation.go index 8d200c38c..fc9e48afc 100644 --- a/services/core/internal/modelconfiguration/observation.go +++ b/services/core/internal/modelconfiguration/observation.go @@ -2,6 +2,20 @@ package modelconfiguration import "slices" +type ProviderErrorCode string + +const ( + ProviderAuthenticationError ProviderErrorCode = "authentication_error" + ProviderConnectionFailed ProviderErrorCode = "connection_failed" + ProviderRateLimitExceeded ProviderErrorCode = "rate_limit_exceeded" + ProviderUsageLimitExceeded ProviderErrorCode = "usage_limit_exceeded" + ProviderServerOverloaded ProviderErrorCode = "server_overloaded" + ProviderServerError ProviderErrorCode = "server_error" + ProviderResourceNotFound ProviderErrorCode = "resource_not_found" + ProviderRequestTimeout ProviderErrorCode = "request_timeout" + ProviderInvalidRequest ProviderErrorCode = "invalid_request" +) + // Observation names a root Turn whose committed outcome may update the // last-use observations of the deployment default its Session froze. type Observation struct { @@ -12,11 +26,7 @@ type Observation struct { // itself. Context-length and cyber-policy failures describe the request. The // observation statement's fence lists the same codes; testdata holds the cases // both are checked against. -var providerErrorCodes = []string{ - "authentication_error", "connection_failed", "rate_limit_exceeded", - "usage_limit_exceeded", "server_overloaded", "server_error", - "resource_not_found", "request_timeout", "invalid_request", -} +var providerErrorCodes = []ProviderErrorCode{ProviderAuthenticationError, ProviderConnectionFailed, ProviderRateLimitExceeded, ProviderUsageLimitExceeded, ProviderServerOverloaded, ProviderServerError, ProviderResourceNotFound, ProviderRequestTimeout, ProviderInvalidRequest} // ShouldObserveProvider reports whether a committed root Turn outcome says // something about its model provider: every completed Turn, and a failed Turn @@ -27,7 +37,7 @@ func ShouldObserveProvider(status, errorCode, engineErrorCode string) bool { case "completed": return true case "failed": - return errorCode == "engine_failed" && slices.Contains(providerErrorCodes, engineErrorCode) + return errorCode == "engine_failed" && slices.Contains(providerErrorCodes, ProviderErrorCode(engineErrorCode)) default: return false } diff --git a/services/core/internal/persistence/postgres/agentpg/store.go b/services/core/internal/persistence/postgres/agentpg/store.go index 40bfcc18e..3265b9a46 100644 --- a/services/core/internal/persistence/postgres/agentpg/store.go +++ b/services/core/internal/persistence/postgres/agentpg/store.go @@ -61,7 +61,7 @@ func (s *Store) CreateAgent(ctx context.Context, input agents.NewAgent) (agents. if created, err = agentFromRow(row); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, input.TenantID, "create", "agent", created.ID, "", writeaudit.Resource{Type: "agent", ID: created.ID}) + return auditpg.RecordWriteAudit(ctx, q, input.TenantID, writeaudit.ActionCreate, writeaudit.ResourceAgent, created.ID, "", writeaudit.Resource{Type: writeaudit.ResourceAgent, ID: created.ID}) }) if err != nil { return agents.Agent{}, translate(err) @@ -130,7 +130,7 @@ func (t *updateTx) apply(revision agents.Revision) (agents.Agent, error) { if err != nil { return agents.Agent{}, err } - return updated, auditpg.RecordWriteAudit(t.ctx, t.q, t.tenantID, "update", "agent", updated.ID, "") + return updated, auditpg.RecordWriteAudit(t.ctx, t.q, t.tenantID, writeaudit.ActionUpdate, writeaudit.ResourceAgent, updated.ID, "") } // DeleteAgent treats an agentID that cannot name an Agent as a missing one. @@ -151,7 +151,7 @@ func (s *Store) DeleteAgent(ctx context.Context, tenantID, agentID string) (stri return err } deleted = uuid.UUID(id.Bytes).String() - return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", "agent", deleted, "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceAgent, deleted, "") }) if err != nil { return "", err diff --git a/services/core/internal/persistence/postgres/auditpg/auditpg_test.go b/services/core/internal/persistence/postgres/auditpg/auditpg_test.go index 1c4e52a38..a0eb6a801 100644 --- a/services/core/internal/persistence/postgres/auditpg/auditpg_test.go +++ b/services/core/internal/persistence/postgres/auditpg/auditpg_test.go @@ -9,16 +9,15 @@ import ( "testing" "time" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) func openAudit(t *testing.T) (*pgunit.Pool, *auditpg.Store) { @@ -46,7 +45,7 @@ func record(t *testing.T, pool *pgunit.Pool, ctx context.Context, write func(con func recordWrite(t *testing.T, pool *pgunit.Pool, source writeaudit.Source, action, kind, id string, created ...writeaudit.Resource) { t.Helper() if err := record(t, pool, writeaudit.WithSource(t.Context(), source), func(ctx context.Context, q *sqlc.Queries) error { - return auditpg.RecordWriteAudit(ctx, q, source.TenantID, action, kind, id, "", created...) + return auditpg.RecordWriteAudit(ctx, q, source.TenantID, writeaudit.Action(action), writeaudit.ResourceType(kind), id, "", created...) }); err != nil { t.Fatal(err) } @@ -204,7 +203,7 @@ func TestMalformedProvenanceFailsClosed(t *testing.T) { id = "" } ctx := writeaudit.WithSource(t.Context(), source) - if err := auditpg.RecordWriteAudit(ctx, nil, valid.TenantID, action, kind, id, "", created...); !errors.Is(err, writeaudit.ErrInvalidSource) { + if err := auditpg.RecordWriteAudit(ctx, nil, valid.TenantID, writeaudit.Action(action), writeaudit.ResourceType(kind), id, "", created...); !errors.Is(err, writeaudit.ErrInvalidSource) { t.Fatalf("%s accepted: %v", field, err) } } diff --git a/services/core/internal/persistence/postgres/auditpg/record.go b/services/core/internal/persistence/postgres/auditpg/record.go index 651afe523..5892ceef7 100644 --- a/services/core/internal/persistence/postgres/auditpg/record.go +++ b/services/core/internal/persistence/postgres/auditpg/record.go @@ -7,14 +7,13 @@ import ( "context" "errors" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // The recorders run in the caller's business transaction: q must belong to it, @@ -26,9 +25,9 @@ import ( // provenance, such as internal lifecycle work, stays unattributed; malformed // provenance fails closed with writeaudit.ErrInvalidSource. A request that // already recorded its operation records nothing more. -func RecordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant, action, resourceType, resourceID, parentID string, created ...writeaudit.Resource) error { +func RecordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant string, action writeaudit.Action, resourceType writeaudit.ResourceType, resourceID, parentID string, created ...writeaudit.Resource) error { if _, ok := adminaudit.FromContext(ctx); ok { - return RecordAdminMutation(ctx, q, tenant, action, resourceType, resourceID) + return RecordAdminMutation(ctx, q, tenant, string(action), string(resourceType), resourceID) } source, ok := writeaudit.FromContext(ctx) if !ok { @@ -45,7 +44,7 @@ func RecordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant, action, reso id, err := q.InsertWriteAuditOperation(ctx, sqlc.InsertWriteAuditOperationParams{ ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, TenantID: tenantID, KeyID: source.KeyID, KeyName: source.Name, KeyPrefix: source.Prefix, KeyKind: source.Kind, - Action: action, ResourceType: resourceType, ResourceID: resourceID, ParentID: parentID, RequestID: source.RequestID, TraceID: source.TraceID, + Action: string(action), ResourceType: string(resourceType), ResourceID: resourceID, ParentID: parentID, RequestID: source.RequestID, TraceID: source.TraceID, }) if errors.Is(err, pgx.ErrNoRows) { return nil @@ -54,7 +53,7 @@ func RecordWriteAudit(ctx context.Context, q *sqlc.Queries, tenant, action, reso return err } for _, resource := range created { - if err := q.InsertWriteAuditOwner(ctx, sqlc.InsertWriteAuditOwnerParams{TenantID: tenantID, ResourceType: resource.Type, ResourceID: resource.ID, ParentID: resource.ParentID, OperationID: id}); err != nil { + if err := q.InsertWriteAuditOwner(ctx, sqlc.InsertWriteAuditOwnerParams{TenantID: tenantID, ResourceType: string(resource.Type), ResourceID: resource.ID, ParentID: resource.ParentID, OperationID: id}); err != nil { return err } } diff --git a/services/core/internal/persistence/postgres/auditpg/write_operations.go b/services/core/internal/persistence/postgres/auditpg/write_operations.go index 30def5fac..37973e396 100644 --- a/services/core/internal/persistence/postgres/auditpg/write_operations.go +++ b/services/core/internal/persistence/postgres/auditpg/write_operations.go @@ -6,13 +6,12 @@ import ( "fmt" "time" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) func apiKey(id, name, prefix, kind string, revoked pgtype.Timestamptz) writeaudit.APIKey { @@ -105,7 +104,7 @@ func (s *Store) ListWriteOperations(ctx context.Context, tenantID string, filter rows = rows[:filter.Limit] } for _, row := range rows { - page.Data = append(page.Data, writeaudit.Operation{ID: uuid.UUID(row.ID.Bytes).String(), Action: row.Action, ResourceType: row.ResourceType, ResourceID: row.ResourceID, ParentID: row.ParentID, RequestID: row.RequestID, TraceID: row.TraceID, APIKey: apiKey(row.KeyID, row.KeyName, row.KeyPrefix, row.KeyKind, row.RevokedAt), CreatedAt: row.CreatedAt.Time}) + page.Data = append(page.Data, writeaudit.Operation{ID: uuid.UUID(row.ID.Bytes).String(), Action: writeaudit.Action(row.Action), ResourceType: writeaudit.ResourceType(row.ResourceType), ResourceID: row.ResourceID, ParentID: row.ParentID, RequestID: row.RequestID, TraceID: row.TraceID, APIKey: apiKey(row.KeyID, row.KeyName, row.KeyPrefix, row.KeyKind, row.RevokedAt), CreatedAt: row.CreatedAt.Time}) } if page.HasMore { page.NextCursor = encodeCursor(page.Data[len(page.Data)-1].ID, scope) diff --git a/services/core/internal/persistence/postgres/deploymentpg/presence_test.go b/services/core/internal/persistence/postgres/deploymentpg/presence_test.go index 7c8fd3905..52b788997 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/presence_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/presence_test.go @@ -8,15 +8,14 @@ import ( "testing" "time" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/deploymentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" ) func presenceContext(t *testing.T) context.Context { @@ -278,15 +277,15 @@ func TestNodeDiagnosticReachesListAndDetail(t *testing.T) { {reported: "dial unix /var/run/docker.sock: permission denied", want: "provider_unavailable"}, {reported: "artifacts_unavailable", want: "", ready: true}, } { - if err := f.service.Heartbeat(t.Context(), node.NodeID, connection, epoch, deployment.NodeHealth{ProviderReady: tc.ready, Diagnostic: tc.reported}); err != nil { + if err := f.service.Heartbeat(t.Context(), node.NodeID, connection, epoch, deployment.NodeHealth{ProviderReady: tc.ready, Diagnostic: sandbox.NodeDiagnosticCode(tc.reported)}); err != nil { t.Fatal(tc.reported, err) } list, err := f.service.ListNodes(t.Context()) - if err != nil || len(list) != 1 || list[0].Diagnostic != tc.want { + if err != nil || len(list) != 1 || string(list[0].Diagnostic) != tc.want { t.Fatal(tc.reported, list, err) } detail, err := f.service.NodeDetail(t.Context(), node.NodeID, "1h") - if err != nil || detail.Diagnostic != tc.want { + if err != nil || string(detail.Diagnostic) != tc.want { t.Fatal(tc.reported, detail.Diagnostic, err) } var stored string diff --git a/services/core/internal/persistence/postgres/filepg/filepg.go b/services/core/internal/persistence/postgres/filepg/filepg.go index 6bac2306c..09a488b87 100644 --- a/services/core/internal/persistence/postgres/filepg/filepg.go +++ b/services/core/internal/persistence/postgres/filepg/filepg.go @@ -7,15 +7,14 @@ import ( "errors" "io" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) type Store struct{ pool *pgunit.Pool } @@ -61,7 +60,7 @@ func (s *Store) Create(ctx context.Context, tenantID string, write func(io.Write return err } created = fileFromRow(row) - return auditpg.RecordWriteAudit(ctx, q, tenantID, "create", "file", created.ID, "", writeaudit.Resource{Type: "file", ID: created.ID}) + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionCreate, writeaudit.ResourceFile, created.ID, "", writeaudit.Resource{Type: writeaudit.ResourceFile, ID: created.ID}) }) if err != nil { return files.File{}, err @@ -207,7 +206,7 @@ func (s *Store) Delete(ctx context.Context, tenantID, fileID string) error { if err := objects.Unlink(ctx, oid.Uint32); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", "file", fileID, "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceFile, fileID, "") }) } diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go index 8e788926f..2c59b8c1b 100644 --- a/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go @@ -12,13 +12,12 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" ) // The statement and ShouldObserveProvider classify the shared cases the same @@ -79,7 +78,7 @@ func TestObservationExcludesOtherSourcesAndHistoricalSessions(t *testing.T) { input.IdempotencyKey = uuid.NewString() projection := *input.ExecutionConfiguration input.ExecutionConfiguration = &projection - input.ModelProviderSource = source + input.ModelProviderSource = v1.ExecutionSource(source) // Historical metadata may name deployment but has no frozen private UUID. if source == "deployment" { input.DeploymentProviderRevision = uuid.Nil @@ -89,7 +88,7 @@ func TestObservationExcludesOtherSourcesAndHistoricalSessions(t *testing.T) { input.ModelProviderSource = "session" } } - projection.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} + projection.ModelProvider = v1.ExecutionProviderSelection{Source: v1.ExecutionSource(source), Status: "available", Configuration: input.ModelProvider.SafeView()} created, err := o.sessions.CreateSession(t.Context(), o.tenant, input) if err != nil { t.Fatal(source, err) diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/store.go b/services/core/internal/persistence/postgres/modelconfigurationpg/store.go index 02a0a7d0d..fd17b9724 100644 --- a/services/core/internal/persistence/postgres/modelconfigurationpg/store.go +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/store.go @@ -10,16 +10,16 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - + "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // auditResource is the administrator audit resource type of a deployment @@ -77,7 +77,7 @@ func (s *Store) Replace(ctx context.Context, record modelconfiguration.Record) ( err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { q := sqlc.New(tx) row, err := q.UpsertDeploymentModelProvider(ctx, sqlc.UpsertDeploymentModelProviderParams{ - Harness: record.Harness, Protocol: record.Provider.Protocol, BaseUrl: record.Provider.BaseURL, + Harness: record.Harness, Protocol: string(record.Provider.Protocol), BaseUrl: record.Provider.BaseURL, ContextWindow: record.Provider.ContextWindow, MaxOutputTokens: record.Provider.MaxOutputTokens, Model: record.Model, HarnessConfig: record.HarnessConfig, EncryptedConfig: sealed, Revision: pgtype.UUID{Bytes: uuid.New(), Valid: true}, @@ -180,10 +180,11 @@ func configuration(row sqlc.ListDeploymentModelProvidersRow) modelconfiguration. result := modelconfiguration.Configuration{ Harness: row.Harness, Model: row.Model, HarnessConfig: json.RawMessage(row.HarnessConfig), UpdatedAt: row.UpdatedAt.Time, LastUsedAt: timestamp(row.LastUsedAt), LastErrorAt: timestamp(row.LastErrorAt), - Provider: v1.ModelProviderView{Protocol: row.Protocol, BaseURL: row.BaseUrl, ContextWindow: row.ContextWindow, MaxOutputTokens: row.MaxOutputTokens, APIKeyConfigured: true}, + Provider: v1.ModelProviderView{Protocol: modelprovider.Protocol(row.Protocol), BaseURL: row.BaseUrl, ContextWindow: row.ContextWindow, MaxOutputTokens: row.MaxOutputTokens, APIKeyConfigured: true}, } if row.LastErrorCode.Valid { - result.LastErrorCode = &row.LastErrorCode.String + code := modelconfiguration.ProviderErrorCode(row.LastErrorCode.String) + result.LastErrorCode = &code } return result } diff --git a/services/core/internal/persistence/postgres/sessionpg/artifacts.go b/services/core/internal/persistence/postgres/sessionpg/artifacts.go index 371e8a0ab..21401d11d 100644 --- a/services/core/internal/persistence/postgres/sessionpg/artifacts.go +++ b/services/core/internal/persistence/postgres/sessionpg/artifacts.go @@ -6,14 +6,14 @@ import ( "fmt" "io" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) var ( @@ -138,7 +138,7 @@ func (s *Store) DeleteSessionArtifact(ctx context.Context, tenantID, sessionID, if err := objects.Unlink(ctx, oid.Uint32); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", "artifact", uuid.UUID(lookup.ID.Bytes).String(), uuid.UUID(lookup.SessionID.Bytes).String()) + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceArtifact, uuid.UUID(lookup.ID.Bytes).String(), uuid.UUID(lookup.SessionID.Bytes).String()) }) } diff --git a/services/core/internal/persistence/postgres/sessionpg/creation.go b/services/core/internal/persistence/postgres/sessionpg/creation.go index be18c68d7..7861947d8 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation.go @@ -6,10 +6,6 @@ import ( "errors" "fmt" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" @@ -24,6 +20,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // modelProviderKeyPurpose keys the provider-key fingerprint in creation @@ -232,7 +231,7 @@ func (t *creationTx) PruneChanges(ctx context.Context) error { } func (t *creationTx) AuditCreation(ctx context.Context, created ...writeaudit.Resource) error { - return auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, "create", "session", optionalID(t.session), "", created...) + return auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, writeaudit.ActionCreate, writeaudit.ResourceSession, optionalID(t.session), "", created...) } func (t *creationTx) LoadSession(ctx context.Context) (sessions.Session, error) { diff --git a/services/core/internal/persistence/postgres/sessionpg/creation_test.go b/services/core/internal/persistence/postgres/sessionpg/creation_test.go index 9a4854546..8e2495694 100644 --- a/services/core/internal/persistence/postgres/sessionpg/creation_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/creation_test.go @@ -14,9 +14,6 @@ import ( "testing" "time" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgxpool" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" @@ -32,6 +29,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" ) var creator = identity.Subject{Kind: "service_account", ID: "test-runner"} @@ -382,7 +381,7 @@ func TestCreationFreezesResourcesOnce(t *testing.T) { input := sessions.CreateSession{ Creator: creator, Engine: "codex", IdempotencyKey: "frozen", Configuration: environmentConfiguration, ModelProvider: &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.com/v1", APIKey: canary}, - ModelProviderSource: v1.ModelProviderSourceSession, + ModelProviderSource: v1.ExecutionSourceSession, Initialization: environmentconfig.Setup{Skills: []environmentconfig.Skill{inline, reference}}, InitialFiles: []environmentconfig.InitialFile{{Type: "inline", Path: "/workspace/a", Data: []byte(canary)}, fileID}, } diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go b/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go index 14cbad562..b6b8a252c 100644 --- a/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go +++ b/services/core/internal/persistence/postgres/sessionpg/execution_file_writes.go @@ -5,13 +5,12 @@ import ( "encoding/json" "errors" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // WithFileWriteReservation reads the Environment in the transaction before it @@ -160,7 +159,7 @@ func (t *fileWriteTx) RecordFileWriteAudit(ctx context.Context, write string) er if err := json.Unmarshal(row.EnvironmentFileWrite.AuditSource, &source); err != nil { return err } - return auditpg.RecordWriteAudit(writeaudit.WithSource(ctx, source), t.q, optionalID(t.tenant), "upload_file", "environment", optionalID(t.environment), optionalID(t.session)) + return auditpg.RecordWriteAudit(writeaudit.WithSource(ctx, source), t.q, optionalID(t.tenant), writeaudit.ActionUploadFile, writeaudit.ResourceEnvironment, optionalID(t.environment), optionalID(t.session)) } func fileWriteFromRow(row sqlc.EnvironmentFileWrite, session pgtype.UUID) sessions.EnvironmentFileWrite { diff --git a/services/core/internal/persistence/postgres/sessionpg/inputs.go b/services/core/internal/persistence/postgres/sessionpg/inputs.go index a55e41aaa..f1f9e7d6c 100644 --- a/services/core/internal/persistence/postgres/sessionpg/inputs.go +++ b/services/core/internal/persistence/postgres/sessionpg/inputs.go @@ -7,14 +7,14 @@ import ( "fmt" "time" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) var ( @@ -184,7 +184,7 @@ func (t *SessionTx) FailInputReservation(ctx context.Context, reservation, code } func (t *SessionTx) RecordInputAudit(ctx context.Context) error { - return auditpg.RecordWriteAudit(ctx, t.q, optionalID(t.tenant), "send_events", "session", optionalID(t.session), "") + return auditpg.RecordWriteAudit(ctx, t.q, optionalID(t.tenant), writeaudit.ActionSendEvents, writeaudit.ResourceSession, optionalID(t.session), "") } func (s *Store) ListTurnInputs(ctx context.Context, tenant, session, turn string, after int64, limit int) ([]sessions.TurnInput, error) { diff --git a/services/core/internal/persistence/postgres/sessionpg/session_reads.go b/services/core/internal/persistence/postgres/sessionpg/session_reads.go index f22c82e04..c987a5fdd 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_reads.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_reads.go @@ -7,14 +7,13 @@ import ( "errors" "fmt" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) var _ sessions.SessionReader = (*Store)(nil) @@ -214,9 +213,9 @@ func (s *Store) GetSessionExecutionConfiguration(ctx context.Context, tenantID, if row.Engine != "" { harness = &row.Engine } - projection.Model = v1.ExecutionSelection{Value: model, Source: "unknown"} - projection.Harness = v1.ExecutionSelection{Value: harness, Source: "unknown"} - projection.ModelProvider = v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} + projection.Model = v1.ExecutionSelection{Value: model, Source: v1.ExecutionSourceUnknown} + projection.Harness = v1.ExecutionSelection{Value: harness, Source: v1.ExecutionSourceUnknown} + projection.ModelProvider = v1.ExecutionProviderSelection{Source: v1.ExecutionSourceUnknown, Status: v1.ExecutionProviderUnavailable} } else if err := json.Unmarshal(row.ExecutionConfiguration, &projection); err != nil { return v1.SessionExecutionConfiguration{}, errors.New("invalid stored session execution configuration") } @@ -261,7 +260,7 @@ func loadManagedArchive(ctx context.Context, q *sqlc.Queries, tenant, session pg if row.EnvironmentType != "openai_hosted" { return sessions.ManagedArchive{}, sessions.ErrInvalidInput } - return sessions.ManagedArchive{SessionID: uuid.UUID(row.SessionID.Bytes).String(), EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), State: row.State}, nil + return sessions.ManagedArchive{SessionID: uuid.UUID(row.SessionID.Bytes).String(), EnvironmentID: uuid.UUID(row.EnvironmentID.Bytes).String(), State: sessions.ManagedArchiveState(row.State)}, nil } // loadSession reads, on q, the tenant's visible Session with the projection diff --git a/services/core/internal/persistence/postgres/sessionpg/session_writes.go b/services/core/internal/persistence/postgres/sessionpg/session_writes.go index f473c62d4..704128714 100644 --- a/services/core/internal/persistence/postgres/sessionpg/session_writes.go +++ b/services/core/internal/persistence/postgres/sessionpg/session_writes.go @@ -5,14 +5,14 @@ import ( "errors" "fmt" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) var _ sessions.SessionStorage = (*Store)(nil) @@ -51,7 +51,7 @@ func (t *deletionTx) ApplyDeletion(ctx context.Context) error { } func (t *deletionTx) RecordDeletionAudit(ctx context.Context) error { - return auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, "delete", "session", uuid.UUID(t.session.Bytes).String(), "") + return auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, writeaudit.ActionDelete, writeaudit.ResourceSession, uuid.UUID(t.session.Bytes).String(), "") } func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID string, encoded []byte) (sessions.Session, error) { @@ -66,7 +66,7 @@ func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID s if err != nil { return err } - if err := auditpg.RecordWriteAudit(ctx, q, tenantID, "update", "session", uuid.UUID(row.ID.Bytes).String(), ""); err != nil { + if err := auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionUpdate, writeaudit.ResourceSession, uuid.UUID(row.ID.Bytes).String(), ""); err != nil { return err } if session, err = sessionFromRow(row); err != nil { @@ -86,6 +86,6 @@ func (s *Store) UpdateSessionMetadata(ctx context.Context, tenantID, sessionID s func (s *Store) AuditSessionOperation(ctx context.Context, tenantID, sessionID, action string) error { return s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - return auditpg.RecordWriteAudit(ctx, q, tenantID, action, "session", uuid.UUID(session.Bytes).String(), "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.Action(action), writeaudit.ResourceSession, uuid.UUID(session.Bytes).String(), "") }) } diff --git a/services/core/internal/persistence/postgres/skillpg/skillpg.go b/services/core/internal/persistence/postgres/skillpg/skillpg.go index b65dfd8eb..cc3089737 100644 --- a/services/core/internal/persistence/postgres/skillpg/skillpg.go +++ b/services/core/internal/persistence/postgres/skillpg/skillpg.go @@ -55,9 +55,9 @@ func (s *Store) CreateSkill(ctx context.Context, in skills.NewSkill) (skills.Ski return err } result = skillFromRow(row) - return auditpg.RecordWriteAudit(ctx, q, in.TenantID, "create", "skill", result.ID, "", - writeaudit.Resource{Type: "skill", ID: result.ID}, - writeaudit.Resource{Type: "skill_version", ID: initial.ID, ParentID: result.ID}) + return auditpg.RecordWriteAudit(ctx, q, in.TenantID, writeaudit.ActionCreate, writeaudit.ResourceSkill, result.ID, "", + writeaudit.Resource{Type: writeaudit.ResourceSkill, ID: result.ID}, + writeaudit.Resource{Type: writeaudit.ResourceSkillVersion, ID: initial.ID, ParentID: result.ID}) }) if err != nil { return skills.Skill{}, translate(err) @@ -87,8 +87,8 @@ func (s *Store) CreateVersion(ctx context.Context, in skills.NewVersion) (skills if err := q.AdvanceSkillVersion(ctx, sqlc.AdvanceSkillVersionParams{TenantID: owner.TenantID, ID: owner.ID, MakeDefault: in.MakeDefault, Name: in.Name, Description: in.Description}); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, in.TenantID, "upload_version", "skill_version", result.ID, result.SkillID, - writeaudit.Resource{Type: "skill_version", ID: result.ID, ParentID: result.SkillID}) + return auditpg.RecordWriteAudit(ctx, q, in.TenantID, writeaudit.ActionUploadVersion, writeaudit.ResourceSkillVersion, result.ID, result.SkillID, + writeaudit.Resource{Type: writeaudit.ResourceSkillVersion, ID: result.ID, ParentID: result.SkillID}) }) if err != nil { return skills.Version{}, translate(err) @@ -116,7 +116,7 @@ func (s *Store) SetDefaultVersion(ctx context.Context, tenantID string, skillID return err } result = skillFromRow(row) - return auditpg.RecordWriteAudit(ctx, q, tenantID, "update_default_version", "skill", result.ID, "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionUpdateDefaultVersion, writeaudit.ResourceSkill, result.ID, "") }) if err != nil { return skills.Skill{}, translate(err) @@ -134,7 +134,7 @@ func (s *Store) DeleteSkill(ctx context.Context, tenantID string, skillID uuid.U if _, err := q.DeleteSkill(ctx, sqlc.DeleteSkillParams{TenantID: tenant, ID: pgID(skillID)}); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", "skill", skills.FormatID(skillID), "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceSkill, skills.FormatID(skillID), "") }) return translate(err) } @@ -194,7 +194,7 @@ func (d *versionDeletion) ApplyVersionDeletion(decision skills.VersionDeletion) } } } - return translate(auditpg.RecordWriteAudit(d.ctx, d.q, d.tenantID, "delete", "skill_version", decision.Target.ID, decision.Target.SkillID)) + return translate(auditpg.RecordWriteAudit(d.ctx, d.q, d.tenantID, writeaudit.ActionDelete, writeaudit.ResourceSkillVersion, decision.Target.ID, decision.Target.SkillID)) } func (s *Store) Skill(ctx context.Context, tenantID string, id uuid.UUID) (skills.Skill, error) { diff --git a/services/core/internal/persistence/postgres/templatepg/store.go b/services/core/internal/persistence/postgres/templatepg/store.go index b4afbb0dc..953324da9 100644 --- a/services/core/internal/persistence/postgres/templatepg/store.go +++ b/services/core/internal/persistence/postgres/templatepg/store.go @@ -23,7 +23,7 @@ import ( ) // resource names Templates in credential bindings and audit rows. -const resource = "environment_template" +const resource = string(writeaudit.ResourceEnvironmentTemplate) // Store implements environmenttemplates.Storage and environmenttemplates.Reader. type Store struct { @@ -62,7 +62,7 @@ func (s *Store) Create(ctx context.Context, tenantID string, in environmenttempl if result, err = template(metadataRow(row)); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, tenantID, "create", resource, result.ID, "", writeaudit.Resource{Type: resource, ID: result.ID}) + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionCreate, writeaudit.ResourceEnvironmentTemplate, result.ID, "", writeaudit.Resource{Type: writeaudit.ResourceEnvironmentTemplate, ID: result.ID}) }) return result, storageError(err) } @@ -90,7 +90,7 @@ func (s *Store) Update(ctx context.Context, tenantID, templateID string, in envi if result, err = template(metadataRow(row)); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, tenantID, "update", resource, result.ID, "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionUpdate, writeaudit.ResourceEnvironmentTemplate, result.ID, "") }) return result, storageError(err) } @@ -108,7 +108,7 @@ func (s *Store) Delete(ctx context.Context, tenantID, templateID string) (string return err } deleted = uuid.UUID(id.Bytes).String() - return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", resource, deleted, "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceEnvironmentTemplate, deleted, "") }) if err != nil { return "", storageError(err) diff --git a/services/core/internal/persistence/postgres/vaultpg/credentials.go b/services/core/internal/persistence/postgres/vaultpg/credentials.go index 478325ddd..852c0448e 100644 --- a/services/core/internal/persistence/postgres/vaultpg/credentials.go +++ b/services/core/internal/persistence/postgres/vaultpg/credentials.go @@ -5,16 +5,15 @@ import ( "encoding/json" "errors" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" ) // CreateCredential admits the owning Vault in the insert itself, so a missing, @@ -46,8 +45,8 @@ func (s *Store) CreateCredential(ctx context.Context, credential vaults.NewCrede if created, err = credentialFromRow(row); err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, credential.TenantID, "create", "credential", created.ID, created.VaultID, - writeaudit.Resource{Type: "credential", ID: created.ID, ParentID: created.VaultID}) + return auditpg.RecordWriteAudit(ctx, q, credential.TenantID, writeaudit.ActionCreate, writeaudit.ResourceCredential, created.ID, created.VaultID, + writeaudit.Resource{Type: writeaudit.ResourceCredential, ID: created.ID, ParentID: created.VaultID}) }) if err != nil { return vaults.Credential{}, err @@ -180,7 +179,7 @@ func (s *Store) ReplaceStaticToken(ctx context.Context, replacement vaults.Stati if err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, replacement.TenantID, "update", "credential", updated.ID, updated.VaultID) + return auditpg.RecordWriteAudit(ctx, q, replacement.TenantID, writeaudit.ActionUpdate, writeaudit.ResourceCredential, updated.ID, updated.VaultID) }) if err != nil { return vaults.Credential{}, err @@ -198,7 +197,7 @@ func (s *Store) DeleteCredential(ctx context.Context, key vaults.CredentialKey) return err } deleted = uuid.UUID(id.Bytes).String() - return auditpg.RecordWriteAudit(ctx, q, key.TenantID, "delete", "credential", deleted, uuid.UUID(vault.Bytes).String()) + return auditpg.RecordWriteAudit(ctx, q, key.TenantID, writeaudit.ActionDelete, writeaudit.ResourceCredential, deleted, uuid.UUID(vault.Bytes).String()) }) if err != nil { return "", err diff --git a/services/core/internal/persistence/postgres/vaultpg/oauth.go b/services/core/internal/persistence/postgres/vaultpg/oauth.go index 07746fe9d..4131ed111 100644 --- a/services/core/internal/persistence/postgres/vaultpg/oauth.go +++ b/services/core/internal/persistence/postgres/vaultpg/oauth.go @@ -4,14 +4,14 @@ import ( "context" "errors" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // WithOAuthCredential holds the Credential's row lock, once loaded, until @@ -64,7 +64,7 @@ func (t *oauthTx) ApplyOAuthReplacement(ctx context.Context, grant vaults.OAuthG if err != nil { return vaults.Credential{}, err } - if err := auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, "update", "credential", updated.ID, updated.VaultID); err != nil { + if err := auditpg.RecordWriteAudit(ctx, t.q, t.tenantID, writeaudit.ActionUpdate, writeaudit.ResourceCredential, updated.ID, updated.VaultID); err != nil { return vaults.Credential{}, translate(err) } return updated, nil diff --git a/services/core/internal/persistence/postgres/vaultpg/vaultpg.go b/services/core/internal/persistence/postgres/vaultpg/vaultpg.go index 1adb7e834..51bf0e2b0 100644 --- a/services/core/internal/persistence/postgres/vaultpg/vaultpg.go +++ b/services/core/internal/persistence/postgres/vaultpg/vaultpg.go @@ -6,10 +6,6 @@ import ( "encoding/json" "errors" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" @@ -17,6 +13,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" ) // Store runs on pooled connections and seals and opens the Credential @@ -81,7 +80,7 @@ func (s *Store) CreateVault(ctx context.Context, vault vaults.NewVault) (vaults. if err != nil { return err } - return auditpg.RecordWriteAudit(ctx, q, vault.TenantID, "create", "vault", created.ID, "", writeaudit.Resource{Type: "vault", ID: created.ID}) + return auditpg.RecordWriteAudit(ctx, q, vault.TenantID, writeaudit.ActionCreate, writeaudit.ResourceVault, created.ID, "", writeaudit.Resource{Type: writeaudit.ResourceVault, ID: created.ID}) }) if err != nil { return vaults.Vault{}, err @@ -164,7 +163,7 @@ func (s *Store) DeleteVault(ctx context.Context, tenantID, vaultID string) (stri return err } deleted = uuid.UUID(id.Bytes).String() - return auditpg.RecordWriteAudit(ctx, q, tenantID, "delete", "vault", deleted, "") + return auditpg.RecordWriteAudit(ctx, q, tenantID, writeaudit.ActionDelete, writeaudit.ResourceVault, deleted, "") }) if err != nil { return "", err diff --git a/services/core/internal/processconfig/config.go b/services/core/internal/processconfig/config.go index 81f2754c7..f5672b59c 100644 --- a/services/core/internal/processconfig/config.go +++ b/services/core/internal/processconfig/config.go @@ -18,9 +18,6 @@ import ( "strings" "time" - "github.com/google/uuid" - "golang.org/x/net/http/httpguts" - "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" @@ -31,6 +28,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/oauthrefresh" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "github.com/google/uuid" + "golang.org/x/net/http/httpguts" ) const ( @@ -176,16 +175,16 @@ func (c Config) Settings() []api.InstallationSetting { origins = []string{} } return []api.InstallationSetting{ - setting("public_url", c.PublicOrigin.String(), nil, []string{"core", "web"}), - setting("log.level", strings.ToLower(c.Log.Level.String()), "info", []string{"core", "web"}), - setting("log.format", format, "auto", []string{"core", "web"}), - setting("log.add_source", c.Log.AddSource, false, []string{"core", "web"}), - setting("core.execution_concurrency", c.ExecutionConcurrency, execution.DefaultExecutionConcurrency, []string{"core"}), - setting("core.harnesses", c.Harnesses, builtin.Kinds(), []string{"core"}), - setting("core.default_harness", c.DefaultHarness, defaultHarness, []string{"core"}), - setting("core.write_audit_retention", duration(c.WriteAuditRetention), duration(defaultWriteAuditRetention), []string{"core"}), - setting("core.oauth_trusted_origins", origins, []string{}, []string{"core"}), - sensitive("core.runtime_history", c.RuntimeHistory.File != "", []string{"core"}), + setting("public_url", c.PublicOrigin.String(), nil, []api.InstallationService{api.InstallationCore, api.InstallationWeb}), + setting("log.level", strings.ToLower(c.Log.Level.String()), "info", []api.InstallationService{api.InstallationCore, api.InstallationWeb}), + setting("log.format", format, "auto", []api.InstallationService{api.InstallationCore, api.InstallationWeb}), + setting("log.add_source", c.Log.AddSource, false, []api.InstallationService{api.InstallationCore, api.InstallationWeb}), + setting("core.execution_concurrency", c.ExecutionConcurrency, execution.DefaultExecutionConcurrency, []api.InstallationService{api.InstallationCore}), + setting("core.harnesses", c.Harnesses, builtin.Kinds(), []api.InstallationService{api.InstallationCore}), + setting("core.default_harness", c.DefaultHarness, defaultHarness, []api.InstallationService{api.InstallationCore}), + setting("core.write_audit_retention", duration(c.WriteAuditRetention), duration(defaultWriteAuditRetention), []api.InstallationService{api.InstallationCore}), + setting("core.oauth_trusted_origins", origins, []string{}, []api.InstallationService{api.InstallationCore}), + sensitive("core.runtime_history", c.RuntimeHistory.File != "", []api.InstallationService{api.InstallationCore}), } } @@ -202,11 +201,11 @@ func duration(d time.Duration) string { return s } -func setting(key string, value, fallback any, restarts []string) api.InstallationSetting { +func setting(key string, value, fallback any, restarts []api.InstallationService) api.InstallationSetting { return api.InstallationSetting{Key: key, Value: value, Default: fallback, Changeable: true, Sensitive: false, Restarts: restarts} } -func sensitive(key string, configured bool, restarts []string) api.InstallationSetting { +func sensitive(key string, configured bool, restarts []api.InstallationService) api.InstallationSetting { return api.InstallationSetting{Key: key, Configured: &configured, Changeable: true, Sensitive: true, Restarts: restarts} } diff --git a/services/core/internal/sandbox/deployment.go b/services/core/internal/sandbox/deployment.go index 4b96acb13..42f9d38ca 100644 --- a/services/core/internal/sandbox/deployment.go +++ b/services/core/internal/sandbox/deployment.go @@ -9,6 +9,14 @@ import ( "regexp" ) +type DeploymentMode string + +const ( + DeploymentUnconfigured DeploymentMode = "" + DeploymentNodes DeploymentMode = "nodes" + DeploymentDirect DeploymentMode = "direct" +) + // ValidationError preserves the sandbox error text and identity while identifying // a fixed configuration field and, for numeric limits, fixed inclusive bounds. type ValidationError struct { @@ -111,7 +119,8 @@ func (s DeploymentSpec) Digest(provider string) string { // Description is what a provider registration says about a deployment of it: // its mode and its backend namespace fingerprint. type Description struct { - Mode, BackendFingerprint string + Mode DeploymentMode + BackendFingerprint string } func BackendFingerprint(kind, namespace string) string { diff --git a/services/core/internal/sandbox/deployment_contract.go b/services/core/internal/sandbox/deployment_contract.go index da40c7ca3..ed929f458 100644 --- a/services/core/internal/sandbox/deployment_contract.go +++ b/services/core/internal/sandbox/deployment_contract.go @@ -41,7 +41,7 @@ var runtimeContract = []runtimeRule{ // ProviderProjection is one registered Provider in the generated projections. type ProviderProjection struct { - Mode string `json:"mode"` + Mode DeploymentMode `json:"mode"` DeploymentPolicy } diff --git a/services/core/internal/sandbox/node/agent.go b/services/core/internal/sandbox/node/agent.go index cb86b6eac..4a5c4abbf 100644 --- a/services/core/internal/sandbox/node/agent.go +++ b/services/core/internal/sandbox/node/agent.go @@ -164,7 +164,7 @@ func (a *agent) health(ctx context.Context, host *hostHealthSampler) (Health, er h.Diagnostic = sandbox.NodeDiagnostic(e) if e != nil && ctx.Err() != nil { // A closing connection cancelled the probe; that says nothing about the provider. - h.Diagnostic = sandbox.NodeProviderUnavailable + h.Diagnostic = string(sandbox.NodeProviderUnavailable) } else { wasReady := a.ready.Swap(h.ProviderReady) seen := a.healthSeen.Swap(true) diff --git a/services/core/internal/sandbox/node/generations.go b/services/core/internal/sandbox/node/generations.go index f538f0643..83354a61a 100644 --- a/services/core/internal/sandbox/node/generations.go +++ b/services/core/internal/sandbox/node/generations.go @@ -75,7 +75,7 @@ func NewGenerationManager(ctx context.Context, options GenerationManagerOptions) cancel() return nil, sandbox.ErrInvalid } - m.values[ref.Generation] = &localGeneration{value: GenerationProvider{Generation: ref.Generation, SpecificationDigest: ref.SpecificationDigest}, state: "failed", diagnostic: sandbox.NodeProviderUnavailable, repairing: true} + m.values[ref.Generation] = &localGeneration{value: GenerationProvider{Generation: ref.Generation, SpecificationDigest: ref.SpecificationDigest}, state: "failed", diagnostic: string(sandbox.NodeProviderUnavailable), repairing: true} } for _, ref := range options.Collect { if !validGeneration(ref.Generation) || !validSpecificationDigest(ref.SpecificationDigest) || m.values[ref.Generation] != nil { diff --git a/services/core/internal/sandbox/node_diagnostic.go b/services/core/internal/sandbox/node_diagnostic.go index efcdedc58..15527a4d2 100644 --- a/services/core/internal/sandbox/node_diagnostic.go +++ b/services/core/internal/sandbox/node_diagnostic.go @@ -32,19 +32,27 @@ func CheckCapacity(r Resources, cpus int, memory uint64) error { return nil } -// NodeProviderUnavailable also reports every readiness failure without a class. -const NodeProviderUnavailable = "provider_unavailable" +type NodeDiagnosticCode string + +const ( + NodeProviderUnavailable NodeDiagnosticCode = "provider_unavailable" + NodeHostUnsupported NodeDiagnosticCode = "host_unsupported" + NodeArtifactsUnavailable NodeDiagnosticCode = "artifacts_unavailable" + NodeRuntimeDownloadFailed NodeDiagnosticCode = "runtime_download_failed" + NodeRuntimeImageUnavailable NodeDiagnosticCode = "runtime_image_unavailable" + NodeCapacityInsufficient NodeDiagnosticCode = "capacity_insufficient" +) var nodeDiagnostics = []struct { err error - code string + code NodeDiagnosticCode }{ {ErrProviderUnavailable, NodeProviderUnavailable}, - {ErrHostUnsupported, "host_unsupported"}, - {ErrArtifactsUnavailable, "artifacts_unavailable"}, - {ErrRuntimeDownloadFailed, "runtime_download_failed"}, - {ErrRuntimeImageUnavailable, "runtime_image_unavailable"}, - {ErrCapacityInsufficient, "capacity_insufficient"}, + {ErrHostUnsupported, NodeHostUnsupported}, + {ErrArtifactsUnavailable, NodeArtifactsUnavailable}, + {ErrRuntimeDownloadFailed, NodeRuntimeDownloadFailed}, + {ErrRuntimeImageUnavailable, NodeRuntimeImageUnavailable}, + {ErrCapacityInsufficient, NodeCapacityInsufficient}, } // NodeDiagnostic maps a readiness probe result to its class code: empty when @@ -55,10 +63,10 @@ func NodeDiagnostic(err error) string { } for _, d := range nodeDiagnostics { if errors.Is(err, d.err) { - return d.code + return string(d.code) } } - return NodeProviderUnavailable + return string(NodeProviderUnavailable) } // NormalizeNodeDiagnostic keeps an empty or known code. Any other reported value @@ -68,9 +76,9 @@ func NormalizeNodeDiagnostic(code string) string { return code } for _, d := range nodeDiagnostics { - if code == d.code { + if code == string(d.code) { return code } } - return NodeProviderUnavailable + return string(NodeProviderUnavailable) } diff --git a/services/core/internal/sandbox/node_diagnostic_test.go b/services/core/internal/sandbox/node_diagnostic_test.go index 84b199cd6..5da89625a 100644 --- a/services/core/internal/sandbox/node_diagnostic_test.go +++ b/services/core/internal/sandbox/node_diagnostic_test.go @@ -20,12 +20,12 @@ func TestNodeDiagnosticContract(t *testing.T) { } var codes []string 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) + codes = append(codes, string(diagnostic.code)) + if got := NodeDiagnostic(fmt.Errorf("private probe detail: %w", diagnostic.err)); got != string(diagnostic.code) { + t.Errorf("wrapped readiness cause = %q, want %q", got, string(diagnostic.code)) } - if got := NormalizeNodeDiagnostic(diagnostic.code); got != diagnostic.code { - t.Errorf("normalized readiness cause = %q, want %q", got, diagnostic.code) + if got := NormalizeNodeDiagnostic(string(diagnostic.code)); got != string(diagnostic.code) { + t.Errorf("normalized readiness cause = %q, want %q", got, string(diagnostic.code)) } } slices.Sort(codes) @@ -36,7 +36,7 @@ func TestNodeDiagnosticContract(t *testing.T) { if NodeDiagnostic(nil) != "" || NormalizeNodeDiagnostic("") != "" { t.Fatal("ready state must have no diagnostic") } - if NodeDiagnostic(errors.New("private probe detail")) != NodeProviderUnavailable || NormalizeNodeDiagnostic("future_code") != NodeProviderUnavailable { + if NodeDiagnostic(errors.New("private probe detail")) != string(NodeProviderUnavailable) || NormalizeNodeDiagnostic("future_code") != string(NodeProviderUnavailable) { t.Fatal("unknown causes must remain provider_unavailable") } } diff --git a/services/core/internal/sandbox/providers/artifacts.go b/services/core/internal/sandbox/providers/artifacts.go index 8db3baa16..d92cc90a0 100644 --- a/services/core/internal/sandbox/providers/artifacts.go +++ b/services/core/internal/sandbox/providers/artifacts.go @@ -7,6 +7,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" ) var nodeProgram = providerassets.Artifact{Path: "native/bin/oac-node", Suffix: "sandbox-node", Role: "node"} @@ -23,7 +24,7 @@ func (r *Registry) ArtifactCatalog() (map[string][]providerassets.Artifact, erro if err != nil { return nil, err } - if a.Mode == "nodes" { + if a.Mode == sandbox.DeploymentNodes { for _, item := range a.NodeArtifacts { previous, exists := paths[item.Path] if exists && previous != item || suffixes[item.Suffix] != "" && suffixes[item.Suffix] != item.Path { diff --git a/services/core/internal/sandbox/providers/config.go b/services/core/internal/sandbox/providers/config.go index 07b0359b1..f530a71e1 100644 --- a/services/core/internal/sandbox/providers/config.go +++ b/services/core/internal/sandbox/providers/config.go @@ -39,7 +39,7 @@ func (r *Registry) Build(config sandbox.NodeConfig, options sandbox.LocalOptions if err != nil { return nil, closeProvider, err } - if adapter.Mode != "nodes" { + if adapter.Mode != sandbox.DeploymentNodes { return nil, closeProvider, fmt.Errorf("%w: selected provider does not support node hosting", sandbox.ErrInvalid) } if config.Generation == 0 { diff --git a/services/core/internal/sandbox/providers/registration.go b/services/core/internal/sandbox/providers/registration.go index 2cf638f56..a4d68dea2 100644 --- a/services/core/internal/sandbox/providers/registration.go +++ b/services/core/internal/sandbox/providers/registration.go @@ -15,14 +15,14 @@ func ValidateRegistration(a Adapter) error { return fmt.Errorf("%w: invalid registration %s", providercontract.ErrContract, field) } switch a.Mode { - case "nodes": + case sandbox.DeploymentNodes: if err := validateNodeArtifacts(a.NodeArtifacts); err != nil { return err } if a.BuildLocal == nil || a.BuildDirect != nil { return invalid("node constructor") } - case "direct": + case sandbox.DeploymentDirect: if len(a.NodeArtifacts) != 0 { return invalid("direct node artifacts") } @@ -53,7 +53,7 @@ func ValidateRegistration(a Adapter) error { } // The common lifecycle suspends only node allocations, so only a nodes // registration may declare checkpoint support. - if operations["Initial"].State == providercontract.Supported && a.Mode != "nodes" { + if operations["Initial"].State == providercontract.Supported && a.Mode != sandbox.DeploymentNodes { return invalid("checkpoint support outside nodes mode") } if a.Policy.DefaultResources != nil && a.ValidateResources(*a.Policy.DefaultResources) != nil { diff --git a/services/core/internal/sandbox/providers/registry.go b/services/core/internal/sandbox/providers/registry.go index fe936c38a..a39c0429f 100644 --- a/services/core/internal/sandbox/providers/registry.go +++ b/services/core/internal/sandbox/providers/registry.go @@ -7,7 +7,6 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/providerassets" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/docker" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" @@ -22,7 +21,7 @@ type Adapter struct { Configuration sandbox.ConfigurationAdapter BuildLocal func(sandbox.NodeConfig, sandbox.LocalOptions, *sandbox.Built) (func(), error) BuildDirect func(sandbox.DirectConfig) (sandbox.SandboxProvider, error) - Mode string + Mode sandbox.DeploymentMode Operations func() providercontract.Operations ValidateSpecification func(sandbox.DeploymentSpec) error ValidateResources func(sandbox.Resources) error @@ -40,18 +39,18 @@ func Builtin() *Registry { return &Registry{adapters: map[string]Adapter{ "docker": { NodeArtifacts: []providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, - Policy: docker.Policy(), Operations: docker.Operations, Mode: "nodes", BuildLocal: docker.BuildNode, + Policy: docker.Policy(), Operations: docker.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: docker.BuildNode, ValidateSpecification: docker.ValidateSpecification, ValidateResources: docker.ValidateResources, Configuration: nodeConfigurationAdapter{docker.ValidateSpecification}, }, "microsandbox": { NodeArtifacts: append([]providerassets.Artifact{nodeProgram, runtimeImage, runtimePolicy}, microsandbox.NodeArtifacts...), - Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: "nodes", BuildLocal: microsandbox.BuildNode, + Policy: microsandbox.Policy(), Operations: microsandbox.Operations, Mode: sandbox.DeploymentNodes, BuildLocal: microsandbox.BuildNode, ValidateSpecification: microsandbox.ValidateSpecification, ValidateResources: microsandbox.ValidateResources, Configuration: nodeConfigurationAdapter{microsandbox.ValidateSpecification}, }, "e2b": { - Policy: e2b.Policy(), Operations: e2b.Operations, Mode: "direct", BuildDirect: e2b.BuildDirect, + Policy: e2b.Policy(), Operations: e2b.Operations, Mode: sandbox.DeploymentDirect, BuildDirect: e2b.BuildDirect, Configuration: e2b.ConfigurationAdapter{}, ValidateSpecification: e2b.ValidateSpecification, ValidateResources: e2b.ValidateResources, }, @@ -104,7 +103,7 @@ func (r *Registry) RetainedLimit(kind string, active, retained int) (int, error) if err != nil { return 0, err } - if a.Mode == "nodes" && a.Operations()["Initial"].State != providercontract.Supported { + if a.Mode == sandbox.DeploymentNodes && a.Operations()["Initial"].State != providercontract.Supported { return active, nil } return retained, nil @@ -132,8 +131,8 @@ func (r *Registry) Describe(kind, installation string) (sandbox.Description, err if e != nil { return sandbox.Description{}, e } - namespace := a.Mode - if a.Mode == "direct" { + namespace := string(a.Mode) + if a.Mode == sandbox.DeploymentDirect { namespace = kind } return sandbox.Description{Mode: a.Mode, BackendFingerprint: sandbox.BackendFingerprint(kind, namespace+":"+installation)}, nil diff --git a/services/core/internal/sandbox/providers/registry_test.go b/services/core/internal/sandbox/providers/registry_test.go index bd6d1ac4f..47a0629bb 100644 --- a/services/core/internal/sandbox/providers/registry_test.go +++ b/services/core/internal/sandbox/providers/registry_test.go @@ -2,11 +2,12 @@ package providers import ( "errors" + "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/providercontract" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/google/uuid" - "testing" ) func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { @@ -22,7 +23,7 @@ func TestRegistrationOwnsDeploymentPolicy(t *testing.T) { } { t.Run(tc.kind, func(t *testing.T) { d, err := registry.Describe(tc.kind, installation) - if err != nil || d.Mode != tc.mode || d.BackendFingerprint != sandbox.BackendFingerprint(tc.kind, tc.namespace+":"+installation) { + if err != nil || string(d.Mode) != tc.mode || d.BackendFingerprint != sandbox.BackendFingerprint(tc.kind, tc.namespace+":"+installation) { t.Fatalf("wrong mode or namespace: %+v %v", d, err) } a, err := registry.Lookup(tc.kind) diff --git a/services/core/internal/sessions/creation.go b/services/core/internal/sessions/creation.go index b6cb3ffa5..f1d5d7e83 100644 --- a/services/core/internal/sessions/creation.go +++ b/services/core/internal/sessions/creation.go @@ -12,8 +12,6 @@ import ( "strconv" "strings" - "github.com/google/uuid" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" @@ -22,6 +20,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" ) // CreationStorage persists Session creation and finds an earlier one. @@ -204,7 +203,7 @@ func (s *Service) createResources(ctx context.Context, tx CreationTx, session Se return nil, err } } - created := []writeaudit.Resource{{Type: "session", ID: session.ID}} + created := []writeaudit.Resource{{Type: writeaudit.ResourceSession, ID: session.ID}} creates, err := createsEnvironment(session.Configuration) if err != nil { return nil, err @@ -214,7 +213,7 @@ func (s *Service) createResources(ctx context.Context, tx CreationTx, session Se if environment, err = tx.CreateEnvironment(ctx); err != nil { return nil, err } - created = append(created, writeaudit.Resource{Type: "environment", ID: environment, ParentID: session.ID}) + created = append(created, writeaudit.Resource{Type: writeaudit.ResourceEnvironment, ID: environment, ParentID: session.ID}) } if hosted { nodes, err := tx.LoadNodes(ctx) @@ -299,7 +298,7 @@ func freezeSkills(ctx context.Context, tx CreationTx, setup environmentconfig.Se // and its provider bundle and completes it as stored. It also returns the // deployment provider revision to record, uuid.Nil for none. The projection is // creation metadata, never retry identity. -func freezeProjection(session Session, frozen v1.SessionExecutionConfiguration, provider *v1.ModelProviderInput, providerSource string, revision uuid.UUID) (v1.SessionExecutionConfiguration, uuid.UUID, error) { +func freezeProjection(session Session, frozen v1.SessionExecutionConfiguration, provider *v1.ModelProviderInput, providerSource v1.ExecutionSource, revision uuid.UUID) (v1.SessionExecutionConfiguration, uuid.UUID, error) { model, err := ExecutionModel(session.Configuration) if err != nil { return v1.SessionExecutionConfiguration{}, uuid.Nil, err @@ -310,24 +309,24 @@ func freezeProjection(session Session, frozen v1.SessionExecutionConfiguration, } recorded := uuid.Nil switch frozen.ModelProvider.Source { - case "deployment": + case v1.ExecutionSourceDeployment: if provider == nil { return v1.SessionExecutionConfiguration{}, uuid.Nil, fmt.Errorf("%w: execution projection has no model provider", ErrInvalidInput) } // The deployment default is readable with the same Core key, so the // safe view is recorded from the frozen bundle itself. Native options // are never part of it. - frozen.ModelProvider.Status = "available" + frozen.ModelProvider.Status = v1.ExecutionProviderAvailable frozen.ModelProvider.Configuration = provider.SafeView() - if providerSource == v1.ModelProviderSourceDeployment { + if providerSource == v1.ExecutionSourceDeployment { recorded = revision } - case "session", "agent": - if provider == nil || frozen.ModelProvider.Status != "available" || frozen.ModelProvider.Configuration == nil || *frozen.ModelProvider.Configuration != *provider.SafeView() { + case v1.ExecutionSourceSession, v1.ExecutionSourceAgent: + if provider == nil || frozen.ModelProvider.Status != v1.ExecutionProviderAvailable || frozen.ModelProvider.Configuration == nil || *frozen.ModelProvider.Configuration != *provider.SafeView() { return v1.SessionExecutionConfiguration{}, uuid.Nil, fmt.Errorf("%w: execution projection does not match model provider", ErrInvalidInput) } - case "unknown": - frozen.ModelProvider.Status = "unavailable" + case v1.ExecutionSourceUnknown: + frozen.ModelProvider.Status = v1.ExecutionProviderUnavailable frozen.ModelProvider.Configuration = nil default: return v1.SessionExecutionConfiguration{}, uuid.Nil, fmt.Errorf("%w: invalid execution projection source", ErrInvalidInput) @@ -340,9 +339,9 @@ func sameExecutionValue(a, b *string) bool { return a == nil && b == nil || a != nil && b != nil && *a == *b } -func validExecutionSource(source string) bool { +func validExecutionSource(source v1.ExecutionSource) bool { switch source { - case "session", "agent", "deployment", "unknown": + case v1.ExecutionSourceSession, v1.ExecutionSourceAgent, v1.ExecutionSourceDeployment, v1.ExecutionSourceUnknown: return true } return false @@ -393,7 +392,7 @@ func prepareCreation(input CreateSession, fingerprint func(string) (string, erro // fingerprint. A deployment default is not caller input: leaving it out // keeps retries equivalent when the default is set, replaced or removed. var fingerprinted *v1.ModelProviderInput - if input.ModelProviderSource != v1.ModelProviderSourceDeployment { + if input.ModelProviderSource != v1.ExecutionSourceDeployment { if fingerprinted, err = fingerprintedProvider(input.ModelProvider, fingerprint); err != nil { return NewSession{}, nil, nil, err } diff --git a/services/core/internal/sessions/creation_test.go b/services/core/internal/sessions/creation_test.go index 95116f097..b906859ef 100644 --- a/services/core/internal/sessions/creation_test.go +++ b/services/core/internal/sessions/creation_test.go @@ -11,8 +11,6 @@ import ( "strings" "testing" - "github.com/google/uuid" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment/placement" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" @@ -20,6 +18,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/skills" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" ) // fakeCreationTx is fakeInputTx with the creation methods, equally strict. @@ -80,7 +79,7 @@ func (f *fakeCreationTx) SaveModelExecution(_ context.Context, provider v1.Model } func (f *fakeCreationTx) SaveExecutionConfiguration(_ context.Context, projection v1.SessionExecutionConfiguration, revision uuid.UUID) error { - f.record("SaveExecutionConfiguration", f.saveExecutionConfiguration != nil, projection.ModelProvider.Status, revision.String()) + f.record("SaveExecutionConfiguration", f.saveExecutionConfiguration != nil, string(projection.ModelProvider.Status), revision.String()) return f.saveExecutionConfiguration() } @@ -111,7 +110,7 @@ func (f *fakeCreationTx) PruneChanges(context.Context) error { func (f *fakeCreationTx) AuditCreation(_ context.Context, created ...writeaudit.Resource) error { var resources []string for _, resource := range created { - resources = append(resources, strings.TrimSuffix(resource.Type+":"+resource.ID+":"+resource.ParentID, ":")) + resources = append(resources, strings.TrimSuffix(string(resource.Type)+":"+resource.ID+":"+resource.ParentID, ":")) } f.record("AuditCreation", f.auditCreation != nil, resources...) return f.auditCreation() @@ -174,7 +173,7 @@ func TestPrepareCreation(t *testing.T) { } return session } - withProvider := func(environment, key, source string) func(*CreateSession) { + withProvider := func(environment, key string, source v1.ExecutionSource) func(*CreateSession) { return func(input *CreateSession) { copy := *provider copy.APIKey = key @@ -202,7 +201,7 @@ func TestPrepareCreation(t *testing.T) { if prepare(t, withProvider("self_hosted", "one", "session"), fingerprints).RequestHash == prepare(t, withProvider("self_hosted", "two", "session"), fingerprints).RequestHash { t.Fatal("caller keys share an identity") } - deployed := prepare(t, withProvider("none", "deployment-key", v1.ModelProviderSourceDeployment), failing) + deployed := prepare(t, withProvider("none", "deployment-key", v1.ExecutionSourceDeployment), failing) plain := prepare(t, func(input *CreateSession) { input.Configuration = creationInput("none").Configuration }, failing) if deployed.RequestHash != plain.RequestHash { t.Fatalf("the deployment default joined the identity: %s", deployed.Configuration) @@ -278,7 +277,7 @@ func TestFreezeProjection(t *testing.T) { model, harness := "gpt", "codex" session := Session{ID: "session", Engine: harness, Configuration: json.RawMessage(`{"agent":{"model":"gpt"}}`)} revision := uuid.New() - projection := func(source, status string, view *v1.ModelProviderView) v1.SessionExecutionConfiguration { + projection := func(source v1.ExecutionSource, status v1.ExecutionProviderStatus, view *v1.ModelProviderView) v1.SessionExecutionConfiguration { return v1.SessionExecutionConfiguration{ Model: v1.ExecutionSelection{Value: &model, Source: "session"}, Harness: v1.ExecutionSelection{Value: &harness, Source: "agent"}, ModelProvider: v1.ExecutionProviderSelection{Source: source, Status: status, Configuration: view}, @@ -290,13 +289,13 @@ func TestFreezeProjection(t *testing.T) { for name, test := range map[string]struct { projection v1.SessionExecutionConfiguration provider *v1.ModelProviderInput - source string - status string + source v1.ExecutionSource + status v1.ExecutionProviderStatus revision uuid.UUID want error hasProjected bool }{ - "deployment records its revision": {projection("deployment", "", nil), provider, v1.ModelProviderSourceDeployment, "available", revision, nil, true}, + "deployment records its revision": {projection("deployment", "", nil), provider, v1.ExecutionSourceDeployment, "available", revision, nil, true}, "deployment from another source": {projection("deployment", "", nil), provider, "session", "available", uuid.Nil, nil, true}, "deployment without a provider": {projection("deployment", "", nil), nil, "", "", uuid.Nil, ErrInvalidInput, false}, "caller provider matches": {projection("session", "available", provider.SafeView()), provider, "session", "available", uuid.Nil, nil, true}, diff --git a/services/core/internal/sessions/execution_configuration.go b/services/core/internal/sessions/execution_configuration.go index 6065dbe97..9349ada3a 100644 --- a/services/core/internal/sessions/execution_configuration.go +++ b/services/core/internal/sessions/execution_configuration.go @@ -16,15 +16,15 @@ func NormalizeExecutionProjection(projection *v1.SessionExecutionConfiguration, projection.Object = "agent.session.execution_configuration" projection.SchemaVersion = 1 if projection.HarnessConfig.Source == "" { - projection.HarnessConfig.Source = "unknown" + projection.HarnessConfig.Source = v1.ExecutionSourceUnknown } projection.HarnessConfig.Value = v1.ResolvedHarnessConfig(projection.HarnessConfig.Value) projection.SessionID = sessionID - if projection.ModelProvider.Source == "deployment" && (projection.ModelProvider.Status != "available" || projection.ModelProvider.Configuration == nil) { + if projection.ModelProvider.Source == v1.ExecutionSourceDeployment && (projection.ModelProvider.Status != v1.ExecutionProviderAvailable || projection.ModelProvider.Configuration == nil) { // Sessions created before deployment defaults moved into Core stay redacted. - projection.ModelProvider.Status = "redacted" + projection.ModelProvider.Status = v1.ExecutionProviderRedacted projection.ModelProvider.Configuration = nil - } else if projection.ModelProvider.Status != "available" { + } else if projection.ModelProvider.Status != v1.ExecutionProviderAvailable { projection.ModelProvider.Configuration = nil } } diff --git a/services/core/internal/sessions/reads.go b/services/core/internal/sessions/reads.go index 9f4a167bd..016220a83 100644 --- a/services/core/internal/sessions/reads.go +++ b/services/core/internal/sessions/reads.go @@ -8,6 +8,14 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" ) +type ManagedArchiveState string + +const ( + ManagedArchiveActive ManagedArchiveState = "active" + ManagedArchiveCleanupPending ManagedArchiveState = "cleanup_pending" + ManagedArchiveReleased ManagedArchiveState = "released" +) + // SessionReader reads Sessions, their public change journal and their // diagnostics. type SessionReader interface { @@ -67,7 +75,7 @@ type TurnDiagnosticsSnapshot struct { // ManagedArchive reports resource disposal, not archive request provenance // or Turn settlement. Existing expiry and failed provisioning use the same states. type ManagedArchive struct { - SessionID string `json:"session_id" binding:"required"` - EnvironmentID string `json:"environment_id" binding:"required"` - State string `json:"state" enums:"active,cleanup_pending,released" binding:"required"` + SessionID string `json:"session_id" binding:"required"` + EnvironmentID string `json:"environment_id" binding:"required"` + State ManagedArchiveState `json:"state" binding:"required"` } diff --git a/services/core/internal/sessions/session.go b/services/core/internal/sessions/session.go index 821aaab46..72f0a35dc 100644 --- a/services/core/internal/sessions/session.go +++ b/services/core/internal/sessions/session.go @@ -7,12 +7,12 @@ import ( "regexp" "time" - "github.com/google/uuid" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/writeaudit" + "github.com/google/uuid" ) var enginePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`) @@ -46,7 +46,7 @@ type CreateSession struct { DeploymentProviderRevision uuid.UUID `json:"-"` ExecutionConfiguration *v1.SessionExecutionConfiguration ModelProvider *v1.ModelProviderInput - ModelProviderSource string // session, agent or deployment + ModelProviderSource v1.ExecutionSource // session, agent or deployment Initialization environmentconfig.Setup InitialFiles []environmentconfig.InitialFile Creator identity.Subject @@ -186,7 +186,7 @@ type AuditSessionOperationCommand struct { // replay on the visible Session. It cannot create ownership or admit execution // work. Another action is ErrInvalidInput. func (s *Service) AuditSessionOperation(ctx context.Context, command AuditSessionOperationCommand) error { - if command.Action != "create" && command.Action != "send_events" { + if command.Action != string(writeaudit.ActionCreate) && command.Action != string(writeaudit.ActionSendEvents) { return ErrInvalidInput } return s.storage.AuditSessionOperation(ctx, command.TenantID, command.SessionID, command.Action) diff --git a/services/core/internal/writeaudit/reader.go b/services/core/internal/writeaudit/reader.go index 367f752df..0b8629e8b 100644 --- a/services/core/internal/writeaudit/reader.go +++ b/services/core/internal/writeaudit/reader.go @@ -36,15 +36,15 @@ type ResourceOwner struct { // Operation is one committed write. type Operation struct { - ID string `json:"id" binding:"required"` - Action string `json:"action" enums:"create,update,delete,send_events,upload_file,upload_version,update_default_version" binding:"required"` - ResourceType string `json:"resource_type" enums:"agent,session,environment,environment_template,skill,skill_version,file,vault,credential,artifact" binding:"required"` - ResourceID string `json:"resource_id" binding:"required"` - ParentID string `json:"parent_id" binding:"required"` - RequestID string `json:"request_id" binding:"required"` - TraceID string `json:"trace_id" binding:"required"` - APIKey APIKey `json:"api_key" binding:"required"` - CreatedAt time.Time `json:"created_at" binding:"required"` + ID string `json:"id" binding:"required"` + Action Action `json:"action" binding:"required"` + ResourceType ResourceType `json:"resource_type" binding:"required"` + ResourceID string `json:"resource_id" binding:"required"` + ParentID string `json:"parent_id" binding:"required"` + RequestID string `json:"request_id" binding:"required"` + TraceID string `json:"trace_id" binding:"required"` + APIKey APIKey `json:"api_key" binding:"required"` + CreatedAt time.Time `json:"created_at" binding:"required"` } // Filter selects committed writes, newest first. A zero Limit is the default diff --git a/services/core/internal/writeaudit/record.go b/services/core/internal/writeaudit/record.go index e7a256a9e..155d0baf1 100644 --- a/services/core/internal/writeaudit/record.go +++ b/services/core/internal/writeaudit/record.go @@ -15,14 +15,44 @@ import ( // vocabulary. The write it belongs to fails closed. var ErrInvalidSource = errors.New("invalid write audit source") +type ResourceType string + +const ( + ResourceAgent ResourceType = "agent" + ResourceSession ResourceType = "session" + ResourceEnvironment ResourceType = "environment" + ResourceEnvironmentTemplate ResourceType = "environment_template" + ResourceSkill ResourceType = "skill" + ResourceSkillVersion ResourceType = "skill_version" + ResourceFile ResourceType = "file" + ResourceVault ResourceType = "vault" + ResourceCredential ResourceType = "credential" + ResourceArtifact ResourceType = "artifact" +) + +type Action string + +const ( + ActionCreate Action = "create" + ActionUpdate Action = "update" + ActionDelete Action = "delete" + ActionSendEvents Action = "send_events" + ActionUploadFile Action = "upload_file" + ActionUploadVersion Action = "upload_version" + ActionUpdateDefaultVersion Action = "update_default_version" +) + // Resource identifies a resource genuinely created by the current transaction. -type Resource struct{ Type, ID, ParentID string } +type Resource struct { + Type ResourceType + ID, ParentID string +} // ValidResourceType reports whether value is an audited resource type. The list // is closed; recording and owner queries share it. func ValidResourceType(value string) bool { - switch value { - case "agent", "session", "environment", "environment_template", "skill", "skill_version", "file", "vault", "credential", "artifact": + switch ResourceType(value) { + case ResourceAgent, ResourceSession, ResourceEnvironment, ResourceEnvironmentTemplate, ResourceSkill, ResourceSkillVersion, ResourceFile, ResourceVault, ResourceCredential, ResourceArtifact: return true } return false @@ -46,17 +76,17 @@ func (s Source) Validate(tenant string) error { // ValidateRecord checks a write record in tenant: source must be well-formed // provenance from tenant, action an audited write action, and every resource // an audited resource. -func ValidateRecord(source Source, tenant, action string, resources []Resource) error { +func ValidateRecord(source Source, tenant string, action Action, resources []Resource) error { if err := source.Validate(tenant); err != nil { return err } switch action { - case "create", "update", "delete", "send_events", "upload_file", "upload_version", "update_default_version": + case ActionCreate, ActionUpdate, ActionDelete, ActionSendEvents, ActionUploadFile, ActionUploadVersion, ActionUpdateDefaultVersion: default: return fmt.Errorf("%w: invalid action", ErrInvalidSource) } for _, resource := range resources { - if !ValidResourceType(resource.Type) || !ValidText(resource.ID, 256, true) || !ValidText(resource.ParentID, 256, false) { + if !ValidResourceType(string(resource.Type)) || !ValidText(resource.ID, 256, true) || !ValidText(resource.ParentID, 256, false) { return fmt.Errorf("%w: invalid resource", ErrInvalidSource) } } diff --git a/services/core/internal/writeaudit/record_test.go b/services/core/internal/writeaudit/record_test.go index 12b721844..ce8d4a5c3 100644 --- a/services/core/internal/writeaudit/record_test.go +++ b/services/core/internal/writeaudit/record_test.go @@ -41,7 +41,7 @@ func TestValidateRecord(t *testing.T) { } { source, action, resources := issuedSource(tenant), "create", agent change(&source, &action, &resources) - if err := ValidateRecord(source, tenant, action, resources); !errors.Is(err, ErrInvalidSource) { + if err := ValidateRecord(source, tenant, Action(action), resources); !errors.Is(err, ErrInvalidSource) { t.Errorf("%s accepted: %v", name, err) } } diff --git a/services/core/tests/integration/admin_session_archive_test.go b/services/core/tests/integration/admin_session_archive_test.go index 52cd739fb..b03bf0cb9 100644 --- a/services/core/tests/integration/admin_session_archive_test.go +++ b/services/core/tests/integration/admin_session_archive_test.go @@ -83,7 +83,7 @@ func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { tenant, session := managedArchiveSession(t, s, input) ctx := adminDeleteContext(t.Context(), tenant, uuid.NewString()) active, err := sessionAdapter(s).GetManagedSessionArchive(t.Context(), tenant, session.ID) - if err != nil || active.State != "active" || active.SessionID != session.ID || active.EnvironmentID != session.Environment.ID { + if err != nil || active.State != sessions.ManagedArchiveActive || active.SessionID != session.ID || active.EnvironmentID != session.Environment.ID { t.Fatal("unallocated Session status", active, err) } for _, generation := range []uint64{0, 2, ^uint64(0)} { @@ -107,7 +107,7 @@ func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { t.Fatal("foreign status", err) } result, err := deploymentExecution(t, w).ArchiveSession(ctx, tenant, session.ID, 1) - if err != nil || result.State != "released" { + if err != nil || result.State != sessions.ManagedArchiveReleased { t.Fatal("unallocated archive", result, err) } row, err := sessionAdapter(s).GetSession(t.Context(), tenant, session.ID) @@ -160,7 +160,7 @@ func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { history := adminMutationSnapshot(t, s, "sessions", "turns", "session_items", "session_artifacts", "source_files", "pg_largeobject", "pg_largeobject_metadata") request := uuid.NewString() result, err := deploymentExecution(t, w).ArchiveSession(adminDeleteContext(t.Context(), tenant, request), tenant, session.ID, 1) - if err != nil || result.State != "cleanup_pending" { + if err != nil || result.State != sessions.ManagedArchiveCleanupPending { t.Fatal(result, err) } assertAdminMutationAudit(t, s, tenant, request, "archive", "session", session.ID) @@ -187,7 +187,7 @@ func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { if _, err := deploymentExecution(t, w).ReleaseAllocation(t.Context(), owner); err != nil { t.Fatal(err) } - if result, err := sessionAdapter(s).GetManagedSessionArchive(t.Context(), tenant, session.ID); err != nil || result.State != "released" { + if result, err := sessionAdapter(s).GetManagedSessionArchive(t.Context(), tenant, session.ID); err != nil || result.State != sessions.ManagedArchiveReleased { t.Fatal("release not reflected", result, err) } page, err := sessionAdapter(s).ListSessionArtifacts(t.Context(), tenant, session.ID, "", "", 100, true) diff --git a/services/core/tests/integration/deployment_model_providers_http_test.go b/services/core/tests/integration/deployment_model_providers_http_test.go index 4afebe86f..298ca9932 100644 --- a/services/core/tests/integration/deployment_model_providers_http_test.go +++ b/services/core/tests/integration/deployment_model_providers_http_test.go @@ -205,7 +205,7 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { } { id := text(call("POST", "/v1/agents/sessions", projectKey, tc.body, 201)["id"]) projection, err := sessionAdapter(st).GetSessionExecutionConfiguration(t.Context(), tenant, id) - if providerOf(id) != tc.key || err != nil || projection.ModelProvider.Source != tc.source { + if providerOf(id) != tc.key || err != nil || string(projection.ModelProvider.Source) != tc.source { t.Fatal("Session did not freeze its provider", name, tc.source, err) } } diff --git a/services/core/tests/integration/fixtures_test.go b/services/core/tests/integration/fixtures_test.go index b78317374..64bda79ef 100644 --- a/services/core/tests/integration/fixtures_test.go +++ b/services/core/tests/integration/fixtures_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" @@ -33,7 +32,7 @@ func WithFixtureModelProvider(input sessions.CreateSession) sessions.CreateSessi if input.ModelProvider != nil { return input } - input.ModelProvider, input.ModelProviderSource = FixtureModelProvider(input.Engine), v1.ModelProviderSourceSession + input.ModelProvider, input.ModelProviderSource = FixtureModelProvider(input.Engine), v1.ExecutionSourceSession if input.ExecutionConfiguration != nil { projection := *input.ExecutionConfiguration projection.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} diff --git a/services/core/tests/integration/node_diagnostic_contract_test.go b/services/core/tests/integration/node_diagnostic_contract_test.go index 6d742c8ed..88cb7ce96 100644 --- a/services/core/tests/integration/node_diagnostic_contract_test.go +++ b/services/core/tests/integration/node_diagnostic_contract_test.go @@ -5,12 +5,11 @@ import ( "os" "reflect" "slices" - "strings" "testing" - "gopkg.in/yaml.v3" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" + "gopkg.in/yaml.v3" ) func TestNodeDiagnosticSchemaContract(t *testing.T) { @@ -35,7 +34,9 @@ func TestNodeDiagnosticSchemaContract(t *testing.T) { if !ok { t.Fatalf("%s has no Diagnostic field", model.Name()) } - check(model.Name(), strings.Split(field.Tag.Get("enums"), ",")) + if field.Type != reflect.TypeFor[sandbox.NodeDiagnosticCode]() { + t.Errorf("%s diagnostic must use the canonical code type", model.Name()) + } } raw, err = os.ReadFile("../../../../contracts/agents-api/core.openapi.yaml") if err != nil { @@ -43,15 +44,27 @@ func TestNodeDiagnosticSchemaContract(t *testing.T) { } var document struct { Definitions map[string]struct { + Enum []string `yaml:"enum"` Properties map[string]struct { - Enum []string `yaml:"enum"` + Ref string `yaml:"$ref"` + AllOf []struct { + Ref string `yaml:"$ref"` + } `yaml:"allOf"` } `yaml:"properties"` } `yaml:"definitions"` } if err := yaml.Unmarshal(raw, &document); err != nil { t.Fatal(err) } + check("sandbox.NodeDiagnosticCode", document.Definitions["sandbox.NodeDiagnosticCode"].Enum) for _, name := range []string{"deployment.Node", "deployment.NodeDetail", "deployment.NodeRollout"} { - check(name, document.Definitions[name].Properties["diagnostic"].Enum) + field := document.Definitions[name].Properties["diagnostic"] + ref := field.Ref + if len(field.AllOf) == 1 { + ref = field.AllOf[0].Ref + } + if ref != "#/definitions/sandbox.NodeDiagnosticCode" { + t.Errorf("%s diagnostic does not reference the canonical enum", name) + } } } diff --git a/services/core/tests/integration/sandbox_generations_test.go b/services/core/tests/integration/sandbox_generations_test.go index e429e6d9a..4040d697b 100644 --- a/services/core/tests/integration/sandbox_generations_test.go +++ b/services/core/tests/integration/sandbox_generations_test.go @@ -5,11 +5,11 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" "github.com/google/uuid" ) @@ -292,14 +292,14 @@ func TestSandboxSnapshotRolloutEquivalence(t *testing.T) { t.Run(state, func(t *testing.T) { connection := onlineManagerNode(t, s, node.NodeID) want := deployment.RolloutNodes{} - wantState := "settled" + wantState := deployment.RolloutSettled switch state { case "ready": want.Ready = 1 case "preparing": heartbeat(connection, "preparing") want.Preparing = 1 - wantState = "preparing" + wantState = deployment.RolloutPreparing case "failed": heartbeat(connection, "failed") want.Failed = 1 diff --git a/services/core/tests/integration/session_execution_configuration_test.go b/services/core/tests/integration/session_execution_configuration_test.go index 82860a5fa..cd84d0590 100644 --- a/services/core/tests/integration/session_execution_configuration_test.go +++ b/services/core/tests/integration/session_execution_configuration_test.go @@ -23,8 +23,8 @@ func executionProjectionInput(source string) sessions.CreateSession { Creator: FixtureCreator(), Engine: harness, IdempotencyKey: uuid.NewString(), Configuration: []byte(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`), ExecutionConfiguration: &v1.SessionExecutionConfiguration{ - Model: v1.ExecutionSelection{Value: &model, Source: source}, - Harness: v1.ExecutionSelection{Value: &harness, Source: source}, + Model: v1.ExecutionSelection{Value: &model, Source: v1.ExecutionSource(source)}, + Harness: v1.ExecutionSelection{Value: &harness, Source: v1.ExecutionSource(source)}, ModelProvider: v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"}, }, } @@ -39,7 +39,7 @@ func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testi tenant := uuid.NewString() input := executionProjectionInput(source) input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://private-deployment.example/v1", APIKey: "private-projection-key-canary"} - input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: v1.ExecutionSource(source), Status: "available", Configuration: input.ModelProvider.SafeView()} input.ExecutionConfiguration.Object = "untrusted-object" input.ExecutionConfiguration.SchemaVersion = 99 input.ExecutionConfiguration.SessionID = "untrusted-session" @@ -60,7 +60,7 @@ func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testi if err != nil { t.Fatal(err) } - if frozen.Object != "agent.session.execution_configuration" || frozen.SchemaVersion != 1 || frozen.SessionID != session.ID || frozen.Model.Source != source || frozen.Harness.Source != source { + if frozen.Object != "agent.session.execution_configuration" || frozen.SchemaVersion != 1 || frozen.SessionID != session.ID || frozen.Model.Source != v1.ExecutionSource(source) || frozen.Harness.Source != v1.ExecutionSource(source) { t.Fatal("incorrect frozen projection identity or provenance") } // Deployment defaults are readable with the same Core key, so every diff --git a/services/core/tests/integration/unified_model_configuration_http_test.go b/services/core/tests/integration/unified_model_configuration_http_test.go index 67ff7202c..6c1191e44 100644 --- a/services/core/tests/integration/unified_model_configuration_http_test.go +++ b/services/core/tests/integration/unified_model_configuration_http_test.go @@ -109,7 +109,7 @@ func TestUnifiedModelConfigurationHTTP(t *testing.T) { if err != nil { t.Fatal(err) } - if snapshot.Model.Value == nil || *snapshot.Model.Value != model || snapshot.Model.Source != modelSource || snapshot.HarnessConfig.Source != nativeSource || snapshot.ModelProvider.Source != providerSource { + if snapshot.Model.Value == nil || *snapshot.Model.Value != model || snapshot.Model.Source != v1.ExecutionSource(modelSource) || snapshot.HarnessConfig.Source != v1.ExecutionSource(nativeSource) || snapshot.ModelProvider.Source != v1.ExecutionSource(providerSource) { t.Fatalf("wrong frozen selections: %#v", snapshot) } equalJSON(snapshot.HarnessConfig.Value, native)