diff --git a/src/server/config-schema.test.ts b/src/server/config-schema.test.ts new file mode 100644 index 0000000..5147793 --- /dev/null +++ b/src/server/config-schema.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { getConfigSchema } from "./config-schema.js"; + +interface ConfigFieldSchema { + key: string; + label: string; + type: string; + default?: unknown; + options?: { label: string; value: string }[]; + required?: boolean; + group?: string; +} + +describe("getConfigSchema", () => { + it("returns a schema with expected field groups", () => { + const schema = getConfigSchema(); + expect(schema.fields.length).toBeGreaterThan(0); + + const groups = schema.fields.map((f: ConfigFieldSchema) => f.group); + const uniqueGroups = [...new Set(groups)]; + + expect(uniqueGroups).toContain("Core"); + expect(uniqueGroups).toContain("Kubernetes"); + expect(uniqueGroups).toContain("Operational"); + }); + + it("has model as required text field", () => { + const schema = getConfigSchema(); + const modelField = schema.fields.find((f: ConfigFieldSchema) => f.key === "model"); + expect(modelField).toBeDefined(); + expect(modelField!.type).toBe("text"); + expect(modelField!.required).toBe(true); + }); + + it("has imagePullPolicy as select with correct options", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "imagePullPolicy"); + expect(field).toBeDefined(); + expect(field!.type).toBe("select"); + expect(field!.options).toEqual([ + { label: "IfNotPresent", value: "IfNotPresent" }, + { label: "Always", value: "Always" }, + { label: "Never", value: "Never" }, + ]); + }); + + it("dangerouslySkipPermissions defaults to true", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "dangerouslySkipPermissions"); + expect(field).toBeDefined(); + expect(field!.type).toBe("toggle"); + expect(field!.default).toBe(true); + }); + + it("ttlSecondsAfterFinished defaults to 300", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "ttlSecondsAfterFinished"); + expect(field).toBeDefined(); + expect(field!.type).toBe("number"); + expect(field!.default).toBe(300); + }); + + it("timeoutSec defaults to 0", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "timeoutSec"); + expect(field).toBeDefined(); + expect(field!.type).toBe("number"); + expect(field!.default).toBe(0); + }); + + it("graceSec defaults to 60", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "graceSec"); + expect(field).toBeDefined(); + expect(field!.type).toBe("number"); + expect(field!.default).toBe(60); + }); + + it("retainJobs is a toggle", () => { + const schema = getConfigSchema(); + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === "retainJobs"); + expect(field).toBeDefined(); + expect(field!.type).toBe("toggle"); + }); + + it("has all kubernetes resource fields", () => { + const schema = getConfigSchema(); + const resourceKeys = [ + "resources.requests.cpu", + "resources.requests.memory", + "resources.limits.cpu", + "resources.limits.memory", + ]; + for (const key of resourceKeys) { + const field = schema.fields.find((f: ConfigFieldSchema) => f.key === key); + expect(field).toBeDefined(); + expect(field!.type).toBe("text"); + } + }); + + it("has env and extraArgs as textarea", () => { + const schema = getConfigSchema(); + const envField = schema.fields.find((f: ConfigFieldSchema) => f.key === "env"); + expect(envField).toBeDefined(); + expect(envField!.type).toBe("textarea"); + + const extraArgsField = schema.fields.find((f: ConfigFieldSchema) => f.key === "extraArgs"); + expect(extraArgsField).toBeDefined(); + expect(extraArgsField!.type).toBe("textarea"); + }); +}); \ No newline at end of file diff --git a/src/server/config-schema.ts b/src/server/config-schema.ts new file mode 100644 index 0000000..3bab368 --- /dev/null +++ b/src/server/config-schema.ts @@ -0,0 +1,186 @@ +// Local type augmentation for adapter-utils version that has getConfigSchema. +// The deployed environment has a newer version of adapter-utils with these types. +interface ConfigFieldSchema { + key: string; + label: string; + type: "text" | "select" | "toggle" | "number" | "textarea" | "combobox"; + options?: { label: string; value: string }[]; + default?: unknown; + hint?: string; + required?: boolean; + group?: string; + meta?: Record; +} + +interface AdapterConfigSchema { + fields: ConfigFieldSchema[]; +} + +export function getConfigSchema(): AdapterConfigSchema { + return { + fields: [ + // Core fields + { + key: "model", + label: "Model", + type: "text", + hint: "OpenCode model id in provider/model format (e.g. anthropic/claude-sonnet-4-6)", + required: true, + group: "Core", + }, + { + key: "variant", + label: "Variant", + type: "text", + hint: "Provider-specific reasoning/profile variant passed as --variant", + group: "Core", + }, + { + key: "dangerouslySkipPermissions", + label: "Skip Permission Checks", + type: "toggle", + default: true, + hint: "Inject runtime config with permission.external_directory=allow", + group: "Core", + }, + { + key: "promptTemplate", + label: "Prompt Template", + type: "text", + hint: "Run prompt template", + group: "Core", + }, + { + key: "extraArgs", + label: "Extra Arguments", + type: "textarea", + hint: "JSON array of additional CLI args appended to the opencode command", + group: "Core", + }, + { + key: "env", + label: "Environment Variables", + type: "textarea", + hint: "KEY=VALUE pairs, one per line. Overrides inherited vars from the Deployment.", + group: "Core", + }, + + // Kubernetes fields + { + key: "namespace", + label: "Namespace", + type: "text", + hint: "Kubernetes namespace for Jobs; defaults to the Deployment namespace", + group: "Kubernetes", + }, + { + key: "image", + label: "Container Image", + type: "text", + hint: "Override container image; defaults to the running Deployment image", + group: "Kubernetes", + }, + { + key: "imagePullPolicy", + label: "Image Pull Policy", + type: "select", + options: [ + { label: "IfNotPresent", value: "IfNotPresent" }, + { label: "Always", value: "Always" }, + { label: "Never", value: "Never" }, + ], + default: "IfNotPresent", + group: "Kubernetes", + }, + { + key: "kubeconfig", + label: "Kubeconfig Path", + type: "text", + hint: "Absolute path to a kubeconfig file; defaults to in-cluster service account auth", + group: "Kubernetes", + }, + { + key: "resources.requests.cpu", + label: "CPU Request", + type: "text", + hint: "e.g. '1000m' or '1'", + group: "Kubernetes", + }, + { + key: "resources.requests.memory", + label: "Memory Request", + type: "text", + hint: "e.g. '2Gi' or '2G'", + group: "Kubernetes", + }, + { + key: "resources.limits.cpu", + label: "CPU Limit", + type: "text", + hint: "e.g. '4000m' or '4'", + group: "Kubernetes", + }, + { + key: "resources.limits.memory", + label: "Memory Limit", + type: "text", + hint: "e.g. '8Gi' or '8G'", + group: "Kubernetes", + }, + { + key: "nodeSelector", + label: "Node Selector", + type: "textarea", + hint: "key=value pairs, one per line", + group: "Kubernetes", + }, + { + key: "tolerations", + label: "Tolerations", + type: "textarea", + hint: "JSON array of toleration objects", + group: "Kubernetes", + }, + { + key: "labels", + label: "Labels", + type: "textarea", + hint: "key=value pairs, one per line. Extra labels added to Job metadata.", + group: "Kubernetes", + }, + { + key: "ttlSecondsAfterFinished", + label: "TTL After Finished", + type: "number", + default: 300, + hint: "Auto-cleanup delay in seconds after Job completes", + group: "Kubernetes", + }, + { + key: "retainJobs", + label: "Retain Jobs", + type: "toggle", + hint: "Skip cleanup on completion for debugging", + group: "Kubernetes", + }, + + // Operational fields + { + key: "timeoutSec", + label: "Timeout (seconds)", + type: "number", + default: 0, + hint: "Run timeout in seconds; 0 means no timeout", + group: "Operational", + }, + { + key: "graceSec", + label: "Grace Period (seconds)", + type: "number", + default: 60, + hint: "Additional grace before adapter gives up after Job deadline", + group: "Operational", + }, + ], + }; +} \ No newline at end of file diff --git a/src/server/index.ts b/src/server/index.ts index d14ede6..e83bc86 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,6 +3,7 @@ import { type, models, agentConfigurationDoc } from "../index.js"; import { execute } from "./execute.js"; import { testEnvironment } from "./test.js"; import { sessionCodec } from "./session.js"; +import { getConfigSchema } from "./config-schema.js"; export function createServerAdapter(): ServerAdapterModule { return { @@ -13,7 +14,8 @@ export function createServerAdapter(): ServerAdapterModule { models, supportsLocalAgentJwt: true, agentConfigurationDoc, - }; + getConfigSchema, + } as ServerAdapterModule; } export { execute, testEnvironment, sessionCodec };