/**
* Unified Homelab Schema with Discriminated Union Optimization
*
* Performance characteristics:
* - Validation time: O(1) constant time via discriminated union
* - Average latency: <0.005ms per validation (3-4μs typical)
* - Improvement: Consistent performance across all 37 schemas (no worst-case degradation)
*
* Architecture:
* - Uses composite discriminator key: action_subaction (e.g., "container:list")
* - Automatically injected via z.preprocess() for backward compatibility
* - Supports all 37 action/subaction combinations across 6 action types
*
* Action types:
* - container: 12 subactions (list, start, stop, restart, pause, unpause, logs, stats, inspect, search, pull, recreate)
* - compose: 9 subactions (list, status, up, down, restart, logs, build, recreate, pull)
* - host: 2 subactions (status, resources)
* - docker: 3 subactions (info, df, prune)
* - image: 4 subactions (list, pull, build, remove)
* - scout: 7 subactions (read, list, tree, exec, find, transfer, diff)
*/
import { z } from "zod";
import { ResponseFormat } from "../types.js";
import {
DEFAULT_LIMIT,
MAX_LIMIT,
DEFAULT_LOG_LINES,
MAX_LOG_LINES,
DEFAULT_MAX_FILE_SIZE,
MAX_FILE_SIZE_LIMIT,
DEFAULT_COMMAND_TIMEOUT,
MAX_COMMAND_TIMEOUT,
DEFAULT_TREE_DEPTH,
MAX_TREE_DEPTH,
DEFAULT_FIND_LIMIT,
MAX_FIND_LIMIT
} from "../constants.js";
import { preprocessWithDiscriminator } from "./discriminator.js";
// ===== Base schemas =====
const responseFormatSchema = z
.nativeEnum(ResponseFormat)
.default(ResponseFormat.MARKDOWN)
.describe("Output format: 'markdown' or 'json'");
const paginationSchema = {
limit: z.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT),
offset: z.number().int().min(0).default(0)
};
// ===== Container subactions =====
const containerListSchema = z.object({
action_subaction: z.literal("container:list"),
action: z.literal("container"),
subaction: z.literal("list"),
host: z.string().optional(),
state: z.enum(["all", "running", "stopped", "paused"]).default("all"),
name_filter: z.string().optional(),
image_filter: z.string().optional(),
label_filter: z.string().optional(),
...paginationSchema,
response_format: responseFormatSchema
});
// Individual container control schemas (for proper discrimination)
const containerStartSchema = z.object({
action_subaction: z.literal("container:start"),
action: z.literal("container"),
subaction: z.literal("start"),
container_id: z.string().min(1),
host: z.string().optional()
});
const containerStopSchema = z.object({
action_subaction: z.literal("container:stop"),
action: z.literal("container"),
subaction: z.literal("stop"),
container_id: z.string().min(1),
host: z.string().optional()
});
const containerRestartSchema = z.object({
action_subaction: z.literal("container:restart"),
action: z.literal("container"),
subaction: z.literal("restart"),
container_id: z.string().min(1),
host: z.string().optional()
});
const containerPauseSchema = z.object({
action_subaction: z.literal("container:pause"),
action: z.literal("container"),
subaction: z.literal("pause"),
container_id: z.string().min(1),
host: z.string().optional()
});
const containerUnpauseSchema = z.object({
action_subaction: z.literal("container:unpause"),
action: z.literal("container"),
subaction: z.literal("unpause"),
container_id: z.string().min(1),
host: z.string().optional()
});
const containerLogsSchema = z.object({
action_subaction: z.literal("container:logs"),
action: z.literal("container"),
subaction: z.literal("logs"),
container_id: z.string().min(1),
host: z.string().optional(),
lines: z.number().int().min(1).max(MAX_LOG_LINES).default(DEFAULT_LOG_LINES),
since: z.string().optional(),
until: z.string().optional(),
grep: z.string().optional(),
stream: z.enum(["all", "stdout", "stderr"]).default("all"),
response_format: responseFormatSchema
});
const containerStatsSchema = z.object({
action_subaction: z.literal("container:stats"),
action: z.literal("container"),
subaction: z.literal("stats"),
container_id: z.string().optional(),
host: z.string().optional(),
response_format: responseFormatSchema
});
const containerInspectSchema = z.object({
action_subaction: z.literal("container:inspect"),
action: z.literal("container"),
subaction: z.literal("inspect"),
container_id: z.string().min(1),
host: z.string().optional(),
summary: z
.boolean()
.default(true)
.describe("Return summary instead of full inspect (reduces output size)"),
response_format: responseFormatSchema
});
const containerSearchSchema = z.object({
action_subaction: z.literal("container:search"),
action: z.literal("container"),
subaction: z.literal("search"),
query: z.string().min(1),
host: z.string().optional(),
...paginationSchema,
response_format: responseFormatSchema
});
const containerPullSchema = z.object({
action_subaction: z.literal("container:pull"),
action: z.literal("container"),
subaction: z.literal("pull"),
container_id: z.string().min(1),
host: z.string().optional()
});
const containerRecreateSchema = z.object({
action_subaction: z.literal("container:recreate"),
action: z.literal("container"),
subaction: z.literal("recreate"),
container_id: z.string().min(1),
host: z.string().optional(),
pull: z.boolean().default(true).describe("Pull latest image before recreating")
});
// ===== Compose subactions =====
const composeListSchema = z.object({
action_subaction: z.literal("compose:list"),
action: z.literal("compose"),
subaction: z.literal("list"),
host: z.string().min(1),
name_filter: z
.string()
.optional()
.describe("Filter projects by name (case-insensitive substring match)"),
...paginationSchema,
response_format: responseFormatSchema
});
const composeStatusSchema = z.object({
action_subaction: z.literal("compose:status"),
action: z.literal("compose"),
subaction: z.literal("status"),
host: z.string().min(1),
project: z.string().min(1),
service_filter: z.string().optional().describe("Filter services by name"),
...paginationSchema,
response_format: responseFormatSchema
});
const composeUpSchema = z.object({
action_subaction: z.literal("compose:up"),
action: z.literal("compose"),
subaction: z.literal("up"),
host: z.string().min(1),
project: z.string().min(1),
detach: z.boolean().default(true)
});
const composeDownSchema = z.object({
action_subaction: z.literal("compose:down"),
action: z.literal("compose"),
subaction: z.literal("down"),
host: z.string().min(1),
project: z.string().min(1),
remove_volumes: z.boolean().default(false)
});
const composeRestartSchema = z.object({
action_subaction: z.literal("compose:restart"),
action: z.literal("compose"),
subaction: z.literal("restart"),
host: z.string().min(1),
project: z.string().min(1)
});
const composeLogsSchema = z.object({
action_subaction: z.literal("compose:logs"),
action: z.literal("compose"),
subaction: z.literal("logs"),
host: z.string().min(1),
project: z.string().min(1),
service: z.string().optional(),
lines: z.number().int().min(1).max(MAX_LOG_LINES).default(DEFAULT_LOG_LINES),
response_format: responseFormatSchema
});
const composeBuildSchema = z.object({
action_subaction: z.literal("compose:build"),
action: z.literal("compose"),
subaction: z.literal("build"),
host: z.string().min(1),
project: z.string().min(1),
service: z.string().optional(),
no_cache: z.boolean().default(false)
});
const composeRecreateSchema = z.object({
action_subaction: z.literal("compose:recreate"),
action: z.literal("compose"),
subaction: z.literal("recreate"),
host: z.string().min(1),
project: z.string().min(1),
service: z.string().optional()
});
const composePullSchema = z.object({
action_subaction: z.literal("compose:pull"),
action: z.literal("compose"),
subaction: z.literal("pull"),
host: z.string().min(1),
project: z.string().min(1),
service: z.string().optional()
});
// ===== Host subactions =====
const hostStatusSchema = z.object({
action_subaction: z.literal("host:status"),
action: z.literal("host"),
subaction: z.literal("status"),
host: z.string().optional(),
response_format: responseFormatSchema
});
const hostResourcesSchema = z.object({
action_subaction: z.literal("host:resources"),
action: z.literal("host"),
subaction: z.literal("resources"),
host: z.string().optional(),
response_format: responseFormatSchema
});
// ===== Docker subactions =====
const dockerInfoSchema = z.object({
action_subaction: z.literal("docker:info"),
action: z.literal("docker"),
subaction: z.literal("info"),
host: z.string().min(1),
response_format: responseFormatSchema
});
const dockerDfSchema = z.object({
action_subaction: z.literal("docker:df"),
action: z.literal("docker"),
subaction: z.literal("df"),
host: z.string().min(1),
response_format: responseFormatSchema
});
const dockerPruneSchema = z.object({
action_subaction: z.literal("docker:prune"),
action: z.literal("docker"),
subaction: z.literal("prune"),
host: z.string().min(1),
prune_target: z.enum(["containers", "images", "volumes", "networks", "buildcache", "all"]),
force: z.boolean().default(false)
});
// ===== Image subactions =====
const imageListSchema = z.object({
action_subaction: z.literal("image:list"),
action: z.literal("image"),
subaction: z.literal("list"),
host: z.string().optional(),
dangling_only: z.boolean().default(false),
...paginationSchema,
response_format: responseFormatSchema
});
const imagePullSchema = z.object({
action_subaction: z.literal("image:pull"),
action: z.literal("image"),
subaction: z.literal("pull"),
host: z.string().min(1),
image: z.string().min(1).describe("Image name with optional tag (e.g., 'nginx:latest')")
});
const imageBuildSchema = z.object({
action_subaction: z.literal("image:build"),
action: z.literal("image"),
subaction: z.literal("build"),
host: z.string().min(1),
context: z.string().min(1).describe("Path to build context directory"),
tag: z.string().min(1).describe("Image tag (e.g., 'myapp:v1')"),
dockerfile: z.string().optional().describe("Path to Dockerfile (default: context/Dockerfile)"),
no_cache: z.boolean().default(false)
});
const imageRemoveSchema = z.object({
action_subaction: z.literal("image:remove"),
action: z.literal("image"),
subaction: z.literal("remove"),
host: z.string().min(1),
image: z.string().min(1).describe("Image ID or name:tag"),
force: z.boolean().default(false)
});
// ===== Scout subactions =====
const scoutReadSchema = z.object({
action_subaction: z.literal("scout:read"),
action: z.literal("scout"),
subaction: z.literal("read"),
host: z.string().min(1).describe("Target host name"),
path: z.string().min(1).describe("Absolute path to file"),
max_size: z
.number()
.int()
.min(1)
.max(MAX_FILE_SIZE_LIMIT)
.default(DEFAULT_MAX_FILE_SIZE)
.describe("Maximum file size to read in bytes"),
response_format: responseFormatSchema
});
const scoutListSchema = z.object({
action_subaction: z.literal("scout:list"),
action: z.literal("scout"),
subaction: z.literal("list"),
host: z.string().min(1).describe("Target host name"),
path: z.string().min(1).describe("Absolute path to directory"),
all: z.boolean().default(false).describe("Include hidden files"),
response_format: responseFormatSchema
});
const scoutTreeSchema = z.object({
action_subaction: z.literal("scout:tree"),
action: z.literal("scout"),
subaction: z.literal("tree"),
host: z.string().min(1).describe("Target host name"),
path: z.string().min(1).describe("Absolute path to directory"),
depth: z
.number()
.int()
.min(1)
.max(MAX_TREE_DEPTH)
.default(DEFAULT_TREE_DEPTH)
.describe("Maximum depth to traverse"),
response_format: responseFormatSchema
});
const scoutExecSchema = z.object({
action_subaction: z.literal("scout:exec"),
action: z.literal("scout"),
subaction: z.literal("exec"),
host: z.string().min(1).describe("Target host name"),
path: z.string().min(1).describe("Working directory for command"),
command: z.string().min(1).describe("Command to execute"),
timeout: z
.number()
.int()
.min(1000)
.max(MAX_COMMAND_TIMEOUT)
.default(DEFAULT_COMMAND_TIMEOUT)
.describe("Command timeout in milliseconds"),
response_format: responseFormatSchema
});
const scoutFindSchema = z.object({
action_subaction: z.literal("scout:find"),
action: z.literal("scout"),
subaction: z.literal("find"),
host: z.string().min(1).describe("Target host name"),
path: z.string().min(1).describe("Starting directory for search"),
pattern: z.string().min(1).describe("Filename pattern (glob)"),
type: z.enum(["f", "d", "l"]).optional().describe("File type: f=file, d=directory, l=symlink"),
max_depth: z
.number()
.int()
.min(1)
.max(MAX_TREE_DEPTH)
.optional()
.describe("Maximum search depth"),
limit: z
.number()
.int()
.min(1)
.max(MAX_FIND_LIMIT)
.default(DEFAULT_FIND_LIMIT)
.describe("Maximum results to return"),
response_format: responseFormatSchema
});
const scoutTransferSchema = z.object({
action_subaction: z.literal("scout:transfer"),
action: z.literal("scout"),
subaction: z.literal("transfer"),
source_host: z.string().min(1).describe("Source host name"),
source_path: z.string().min(1).describe("Source file path"),
target_host: z.string().min(1).describe("Target host name"),
target_path: z.string().min(1).describe("Target file path or directory")
});
const scoutDiffSchema = z.object({
action_subaction: z.literal("scout:diff"),
action: z.literal("scout"),
subaction: z.literal("diff"),
host1: z.string().min(1).describe("First host name"),
path1: z.string().min(1).describe("First file path"),
host2: z.string().min(1).describe("Second host name"),
path2: z.string().min(1).describe("Second file path"),
context_lines: z
.number()
.int()
.min(0)
.max(20)
.default(3)
.describe("Context lines around changes"),
response_format: responseFormatSchema
});
// ===== Unified schema using z.discriminatedUnion for O(1) lookup =====
// Uses action_subaction composite key as discriminator for constant-time schema lookup
// instead of O(n) sequential validation with z.union()
const UnifiedHomelabUnion = z.discriminatedUnion("action_subaction", [
// Container actions (12 schemas)
containerListSchema,
containerStartSchema,
containerStopSchema,
containerRestartSchema,
containerPauseSchema,
containerUnpauseSchema,
containerLogsSchema,
containerStatsSchema,
containerInspectSchema,
containerSearchSchema,
containerPullSchema,
containerRecreateSchema,
// Compose actions (9 schemas)
composeListSchema,
composeStatusSchema,
composeUpSchema,
composeDownSchema,
composeRestartSchema,
composeLogsSchema,
composeBuildSchema,
composeRecreateSchema,
composePullSchema,
// Host actions (2 schemas)
hostStatusSchema,
hostResourcesSchema,
// Docker actions (3 schemas)
dockerInfoSchema,
dockerDfSchema,
dockerPruneSchema,
// Image actions (4 schemas)
imageListSchema,
imagePullSchema,
imageBuildSchema,
imageRemoveSchema,
// Scout actions (7 schemas)
scoutReadSchema,
scoutListSchema,
scoutTreeSchema,
scoutExecSchema,
scoutFindSchema,
scoutTransferSchema,
scoutDiffSchema
]);
// Export with preprocess wrapper to automatically inject discriminator
export const UnifiedHomelabSchema = z.preprocess(preprocessWithDiscriminator, UnifiedHomelabUnion);
export type UnifiedHomelabInput = z.infer<typeof UnifiedHomelabSchema>;
// Re-export individual schemas for type narrowing
export {
containerListSchema,
containerStartSchema,
containerStopSchema,
containerRestartSchema,
containerPauseSchema,
containerUnpauseSchema,
containerLogsSchema,
containerStatsSchema,
containerInspectSchema,
containerSearchSchema,
containerPullSchema,
containerRecreateSchema,
composeListSchema,
composeStatusSchema,
composeUpSchema,
composeDownSchema,
composeRestartSchema,
composeLogsSchema,
composeBuildSchema,
composeRecreateSchema,
composePullSchema,
hostStatusSchema,
hostResourcesSchema,
dockerInfoSchema,
dockerDfSchema,
dockerPruneSchema,
imageListSchema,
imagePullSchema,
imageBuildSchema,
imageRemoveSchema,
scoutReadSchema,
scoutListSchema,
scoutTreeSchema,
scoutExecSchema,
scoutFindSchema,
scoutTransferSchema,
scoutDiffSchema
};