> For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt.

> **Themelio compatibility reference.** This page describes the retained Themelio implementation or its historical design. Check the [parallel-operation guide](/compatibility/index.md) before applying it to the current platform.

# DD-0013: actions.yaml Automation System — Implementation

**Related ADR:** [ADR-0013: actions.yaml Automation System](/adr/0013-actions-yaml-automation-system.md)

**Status:** Draft

**Date:** 2026-06-04

**Authors:** TBD

## ADR-derived technical notes

> These notes are reconciled against the **current code** (`services/web` and `services/k8s-root-actor`). Where the ADR description and the implementation diverge, the code wins and the gap is flagged. The [Design](#design) section below distinguishes _as-built_ from _target_ throughout.

### YAML Schema Specification

The `actions.yaml` file at the root of a GitLab repository defines a map of named actions. The fields the runtime actually consumes today are a subset of the documented schema:

```yaml
action_name:
  title: "Display Title"             # Optional, shown in UI (loadActionsYaml)
  label: "Button Label"              # Optional, falls back to title
  type: "slurm-job"                  # Required: slurm-job | k8s-deployment | k8s-inferenceservice
  ref: "#/references/gradio-space"   # Optional, server-side reference (resolved by k8s-root-actor)
  parameters:                        # Optional, map of parameter definitions (UI only).
    param_name:                      # Each parameter follows the JSON Schema specification.
      type: "string"                 # JSON Schema types: string | number | integer | boolean
      title: "Parameter Display Name"
      default: "default_value"
      enum: ["a", "b"]               # JSON Schema enum for constrained values
  repos:                             # Optional, extra repositories to clone (the repo holding
    - url: "https://gitlab.example.com/repo.git"   # actions.yaml is always cloned too — see below)
      dir: "repo"                    # Optional, derived from URL if omitted
  env:                               # Optional, "KEY=value" strings (parseActionEnv).
    - "KEY=value"                    # Consumed by all three action types (2026-06-07).
  pre:                               # Optional, pre-execution commands
    - "echo 'Setting up'"
  script: |                          # Required
    #!/bin/bash
    echo "Running with parameter: $param_name"
  post:                              # Optional, post-execution commands (slurm-job only today)
    - "echo 'Cleaning up'"
  options:                           # Optional, action-specific options
    cpus: 4
    ram: 16                          # interpreted as Gi by templates
    gpus: 1
    gpuLabel: nvidia.com/a100        # whole A100; use nvidia.com/a100-mig-1g.5gb for a MIG slice
    port: 8080
    modelName: "$param_name"
```

The TypeScript shape that `k8s-root-actor` deserializes into is `ActionConfig` in `services/k8s-root-actor/src/types/action.ts`. Note `env` is a `string[]` of `KEY=value` entries (not a map), and `options` keys are `cpus | ram | gpus | gpuLabel | port | modelName`. `gpuLabel` (k8s-deployment / k8s-inferenceservice only) selects the GPU resource key — for example, `nvidia.com/a100` for a whole A100 or `nvidia.com/a100-mig-1g.5gb` for a MIG slice; an unsupported value is rejected with HTTP 400. **There is no Zod/JSON-Schema validation today** — `js-yaml` output is cast to `ActionConfig`.

Field semantics worth calling out:

- **`parameters`** — each parameter definition follows the [JSON Schema](https://json-schema.org/) specification (`type`, `title`, `default`, `enum`, etc.). The web UI builds its input controls from these definitions, and the target validation (see [Design §7](#7-parameter-substitution--security-gap)) checks user-supplied values against the JSON Schema before substitution.
- **`repos`** — an optional list of extra repositories to clone into the workspace. The repository that **contains the `actions.yaml`** (the "main repo") is always cloned first, independently of this list. For any repository whose URL is on the Themelio GitLab domain, a short-lived user GitLab PAT is automatically minted and injected (`oauth2:<token>@…`) so the clone can authenticate; non-Themelio URLs are cloned as-is. See the cloning logic in `services/k8s-root-actor/src/templates/k8s-common/prepare.sh.hbs` and [Design §4.2](#42-k8s-deployment--srclibaction-k8s-deploymentts).
- **`env`** — `KEY=value` entries parsed by `parseActionEnv`. **As of 2026-06-07 all three action types consume `env`**: `k8s-deployment`/`k8s-inferenceservice` render it into the manifest env block (`yamlq`-quoted), and `slurm-job` emits `export NAME='value'` lines (`formatEnvExports`, `shq`-quoted) into the remote `script.sh`.

### Parameter substitution (as-built — known gap)

> **Update (2026-06-07): closed.** This naive replace was superseded by validated, context-escaped substitution (`validateParameterValue` + `shq`/`yamlq` in `action-executor.ts`). See [Design §7](#7-parameter-substitution--security-gap). The snippet below is the original pre-hardening code, kept for history.

Substitution happens in the **web service**, before `k8s-root-actor` is called, in `services/web/src/utils/actions/action-executor.ts`:

```ts
for (const [parameterKey, parameterValue] of Object.entries(parameters)) {
  actionsYaml = actionsYaml.replace(new RegExp(`\\$${parameterKey}`, 'g'), parameterValue);
}
```

This is a **naive global string replace on the raw YAML text** — values are **not shell-quoted, not YAML-escaped, and not validated** against the declared parameter `type`. This is an injection risk and is treated as a gap to close (see [Design §7](#7-parameter-substitution--security-gap)), not the target behavior.

### Template system (Handlebars)

Kubernetes manifests are rendered from Handlebars templates under `services/k8s-root-actor/src/templates/` (compiled on every render in `src/utils/template-renderer.ts` — there is no template cache). The templates that actually exist:

- **slurm-job**: `slurm-job/script.sh.hbs`, `slurm-job/k8s-job.yaml.hbs`
- **k8s-deployment**: `k8s-deployment/app.yaml.hbs`, plus shared `k8s-common/prepare.sh.hbs`, `k8s-common/PersistentVolumeClaim.yaml.hbs`, `k8s-common/Service.yaml.hbs`, `k8s-common/VirtualService.yaml.hbs`, `k8s-common/Gateway.yaml.hbs`
- **k8s-inferenceservice**: `k8s-inferenceservice/app.yaml.hbs`, plus the same `k8s-common/*` shared templates

The registered helpers (in `template-renderer.ts`) are:

| Helper        | Purpose                                                         |
| ------------- | --------------------------------------------------------------- |
| `join`        | Join an array with a separator                                  |
| `shq`         | Shell-quote a value (`'…'` with embedded single-quotes escaped) |
| `yamlq`       | YAML/JSON-quote a scalar (`JSON.stringify`)                     |
| `capitalize`  | Capitalize the first character                                  |
| `toUpperCase` | Uppercase the string                                            |

There is no `base64`/`toLower` helper; base64 encoding is done in TypeScript (`Buffer.from(...).toString('base64')`) before the value is passed into the template. **`shq` exists but is not applied to user parameters** — closing that loop is part of the security target.

### Reference resolution

Server-side references are resolved by `populateRefs()` in `services/k8s-root-actor/src/utils/actions-yaml-references.ts`. Reference files live under `services/k8s-root-actor/src/actions-yaml-references/` (`.yaml`, `.yml`, `.json`); one ships today: `gradio-space.yaml`. Path traversal is blocked by `validatePathInDirectory()` (resolved path must stay within the references dir). Nested references **are** resolved (the merged object is re-processed recursively), but there is **no cycle detection**.

### SLURM specifics

The `slurm-job` k8s Job SSHes to a single external SLURM cluster using the Kubernetes secret **`themelio-hpc-ssh`** (keys: `id_ed25519`, `known_hosts`, `ssh_user`, `ssh_host`, `remote_script_path`), mounted read-only at `/ssh`. The remote script is `sbatch`-submitted inside a `screen` session. Both the cluster and secret name are hardcoded in `slurm-job/k8s-job.yaml.hbs`. This single-cluster/single-account model is a deliberate temporary solution; a future per-project SLURM-account binding is anticipated (see [Design §4.1](#41-slurm-job--srclibaction-slurm-jobts)).

### GitLab token scopes (as-built)

**Implemented (2026-06-07):** the web service mints a short-lived GitLab PAT named after the action with scopes **`['read_repository', 'write_repository']`** only (`action-executor.ts`). The broad `api` scope was dropped — actions only clone/push repository content. _(Originally as-built this also included `api`.)_

## Overview

This document specifies how Themelio turns a declarative `actions.yaml` file at the root of a GitLab repository into running compute. The web service discovers actions, collects parameters, performs substitution, persists a job record, and proxies execution to `k8s-root-actor`; `k8s-root-actor` resolves references, renders Handlebars templates, and applies Kubernetes/SLURM resources for one of three action types (`slurm-job`, `k8s-deployment`, `k8s-inferenceservice`). It documents the system **as built today**, the **target** design it should converge to (secure substitution, ADR-0012-backed persistence and destroy, typed parameter UI, UI-guided generation), and the **gap** between them so an implementer knows exactly what remains.

:::note Implementation status (reconciled 2026-06-07)
Most of the "target / gap" items below have since shipped. The sections retain their as-built→target framing for history, but the current state is:

| Target (section)                                   | Status                                                                                                                                             |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Parameter validation + `shq`/`yamlq` escaping (§7) | **Implemented** (`web/src/utils/actions/action-executor.ts`)                                                                                       |
| Drop `api` GitLab PAT scope                        | **Implemented** — minted with `read_repository`/`write_repository` only                                                                            |
| Reference depth-3 limit + cycle detection (§6)     | **Implemented** (`MAX_REFERENCE_DEPTH`, `CircularReferenceError`, `ReferenceDepthError`)                                                           |
| Typed parameter input UI (§11)                     | **Implemented** (`ActionParametersModal.tsx`)                                                                                                      |
| UI-guided `actions.yaml` creation (§10)            | **Implemented** (`RepositoryForm.tsx` + `api_createActionsFile`); a `huggingface-inference.yaml` reference now ships alongside `gradio-space.yaml` |
| Destroy — deployments (§9)                         | **Implemented** (`DeploymentManager.tsx` → `DELETE …/deployments/{id}`)                                                                            |
| Destroy — jobs UI (§9)                             | **Implemented** (`JobManager.tsx` Delete button → `DELETE …/jobs/{id}`)                                                                            |
| `env` consumed by all three action types           | **Implemented** — `slurm-job` and `k8s-inferenceservice` now honor `env` (previously `k8s-deployment` only)                                        |
| `ActionConfig` Zod validator (§2)                  | **Not implemented** — still an open target; structure is `js-yaml`-cast (per-value validation exists, structural does not)                         |

The formal **Status** stays `Draft` because ADR-0013 is still `Proposed`.
:::

## Goals and Non-Goals

### Goals

- Provide an accurate, implementer-facing specification of the three action types and their end-to-end execution path, naming the actual files and symbols.
- Close the parameter-handling gap: validate parameter values against their declared `type` and quote them safely (using the existing `shq`/`yamlq` helpers) so user input cannot break out of the script or manifest.
- Render every declared parameter `type` (`string`, `number`, `boolean`, `enum`, `file`) with an appropriate input control in the web UI.
- Create the ADR-0012 record at action execution and surface per-resource destroy in the UI.
- Auto-generate `actions.yaml` during **model and space** creation only, driven by the web UI: a `huggingface`-format model → `k8s-inferenceservice`, a `gradio` space → `k8s-deployment`.

### Non-Goals

- **Re-specifying ADR-0012's persistence schema.** The `jobs`/`deployments`/`*_logs` tables, secret model, and CRUD/destroy endpoints are defined in [DD-0012](/dd/0012-job-deployment-management-system.md); this DD only describes how actions _integrate_ with them.
- Multi-step workflows or dependencies between actions.
- Multiple SLURM clusters or dynamic cluster/account selection (single hardcoded cluster + single shared SLURM account via the `themelio-hpc-ssh` secret). This is a deliberate temporary stance — binding projects/namespaces to specific SLURM accounts is future work.
- Per-action quota enforcement. Resource quotas are owned by an **external quota mechanism managed by a Waldur site agent** at the namespace/SLURM-account level; this system does not cap or duplicate `cpus/ram/gpus` enforcement per action.
- Container image caching across executions.

## Design

### 1. Architecture & responsibilities

Two services collaborate; the boundary is that **the web service pre-resolves the action config** and only sends the single selected action to `k8s-root-actor`.

| Concern                                | Web service (`services/web`)           | k8s-root-actor (`services/k8s-root-actor`) |
| -------------------------------------- | -------------------------------------- | ------------------------------------------ |
| Fetch `actions.yaml`                   | Yes — GitLab raw file API              | No                                         |
| Parameter substitution                 | Yes (`action-executor.ts`)             | No                                         |
| Parse + select action                  | Yes (`js-yaml`, `actions[actionName]`) | Receives resolved `actionConfig`           |
| Reference resolution (`#/references/`) | No                                     | Yes (`populateRefs`)                       |
| Template rendering                     | No                                     | Yes (`renderTemplate`)                     |
| `kubectl`/SLURM apply                  | No                                     | Yes (`kubectlCommands`, SSH)               |
| Job/record tracking                    | Delegates to the ADR-0012 layer        | Owns ADR-0012 records                      |

### 2. actions.yaml format & parsing

Two parse paths exist, both using `js-yaml`:

1. **Listing for the UI** — `loadActionsYaml()` (`services/web/src/utils/actions/action-loader.ts`) fetches `…/repository/files/actions.yaml/raw?ref={branch}` and projects each entry to a `CustomAction` (`name`, `title`, `label`, `parameters`). Missing/invalid files degrade gracefully to `[]`. This is invoked from the resource-detail GET routes (e.g. `services/web/src/app/api/models/[namespace]/[repo]/route.ts`), so the action list rides along on the model/space payload — **there is no dedicated `/actions/[branch]` endpoint**.
2. **Execution** — `executeAction()` (`action-executor.ts`) re-fetches the raw YAML at the target branch, substitutes parameters, parses, and selects `actions[actionName]`.

**Target:** introduce a shared schema validator (Zod) for `ActionConfig` used by `executeAction` before dispatch, returning a 400 with field-level errors instead of casting `js-yaml` output.

### 3. Action execution flow (as-built)

```
User clicks action (ResourceActions.tsx) ─ optional ActionParametersModal
  ↓ POST /api/{models|datasets|spaces|repos}/{ns}/{repo}/action/{branch}
     body: { action, parameters }
Web: handleActionRoute → executeAction (action-executor.ts)
  ↓ fetch actions.yaml(raw, ref=branch) → substitute $params → parse → select action
  ↓ guard: getRepoJobs({ idPrefix, onlyRunning }) → 409 if already running
  ↓ createJobEntry(...) + pushJobLog("INITIALIZED")   [as-built: always a "job"; target: branch job vs deployment per type — see §8]
  ↓ createPersonalAccessToken(scopes: read_repository, write_repository)   [target: drop `api`]
  ↓ kubeRequest POST {K8S_ROOT_ACTOR_URL}/{repoType}/{ns}/{repoId}/{branch}/action
     body: { actionName, actionJobName, actionConfig, gitlabToken }
k8s-root-actor (index.ts):
  ↓ populateRefs(actionConfig)        → 400 traversal / 404 missing ref / 500 parse
  ↓ namespace = sanitizeK8sName(namespace)
  ↓ switch(resolvedConfig.type):
       slurm-job             → actionSlurmJob
       k8s-deployment        → actionK8sDeployment
       k8s-inferenceservice  → actionK8sInferenceService
  ↓ renderTemplate(...) → kubectl{Create|Apply}Template / Service / VirtualService / Gateway / PVC
  ↓ return { status: "pending" | "conflict"(409) | "error"(400/404/500), ... }
Web: pushJobLog("K8S_JOB_SUBMITTED" | "K8S_JOB_FAILED") → return to UI → redirect to #jobs
```

### 4. Action types

All three handlers receive the resolved `Action` (`src/types/action.ts`) and base64-encode rendered scripts before embedding them in manifests.

#### 4.1 slurm-job — `src/lib/action-slurm-job.ts`

Renders `slurm-job/script.sh` (with `repos`, `pre`, `post`, `env` as `export NAME='value'` lines via `formatEnvExports`, `script`, and `cpus`/`ram`/`gpus` defaulting to `1`/`4`/`0`), base64-encodes it, and creates a Kubernetes `batch/v1` Job from `slurm-job/k8s-job.yaml`. The Job container `scp`s the script to the SLURM head node via the `themelio-hpc-ssh` secret and launches it under `screen` + `sbatch`. No PVC, Service, or external URL. Maps to ADR-0012's `jobs` table.

A single SLURM cluster and a single shared SLURM account back all `slurm-job` actions, with the cluster and `themelio-hpc-ssh` secret hardcoded in the template. This is an **explicitly temporary** arrangement: there is no per-project or per-action cluster/account selection today. The intended future direction is to bind each Themelio project/namespace to a specific SLURM account, so the substitution and secret-resolution paths should be written with that eventual per-project mapping in mind rather than hardcoding assumptions that would be costly to unwind.

#### 4.2 k8s-deployment — `src/lib/action-k8s-deployment.ts`

Computes a stable name via `buildStableActionName([repoType, namespace, repoId, branchName, actionName])` (sanitized prefix + 8-char SHA1), then:

1. `kubectlResourceExists(...)` → throws `K8sResourceAlreadyExistsError` (surfaced as **409 conflict**) if the deployment exists.
2. Creates a `10Gi` PVC (`{name}-workspace`).
3. Renders `k8s-common/prepare.sh` as an init script: always clones the **main repo** (the one holding `actions.yaml`, at `{THEMELIO_URL}/{repoType}/{namespace}/{repoId}.git`) with `oauth2:{gitlabToken}@` injected, then clones each entry in `repos` — injecting `oauth2:{gitlabToken}@` for Themelio-hosted URLs and cloning others as-is — and finally runs `pre`. (`sync_or_clone_repo` re-syncs an existing checkout instead of re-cloning.)
4. Applies `k8s-deployment/app.yaml` (init container + main container running the base64 script, `env` from `parseActionEnv`, `cpus/ram/gpus/port`).
5. Applies Service, Istio VirtualService, and Gateway (`service-gateway`) for an external URL.

Returns `{ deploymentName, pvcName, url, internalURL }`. Maps to ADR-0012's `deployments` table.

#### 4.3 k8s-inferenceservice — `src/lib/action-k8s-inferenceservice.ts`

Same stable-name + init-script approach, but applies a KServe `InferenceService` (`k8s-inferenceservice/app.yaml`, vLLM serving) with a PVC mounted at `/mnt/models`, `env` from `parseActionEnv` appended to the model-server env block, plus VirtualService and Gateway. Maps to ADR-0012's `deployments` table.

### 5. Template system

Templates are read and compiled on **every** call to `loadTemplate` (no caching — edits to `.hbs` files take effect immediately) and rendered with `renderTemplate(name, context)`. Helpers available: `join`, `shq`, `yamlq`, `capitalize`, `toUpperCase` (see notes above). Scripts are base64-encoded in TypeScript before templating; manifests are piped to `kubectl` via stdin by `src/utils/kubectlCommands.ts`.

**Target / gap:** route every user-supplied parameter through `shq` (for shell context) or `yamlq` (for manifest scalars) at substitution time, so quoting is structural rather than relying on the author of `actions.yaml`. _(Implemented 2026-06-07 — see §7.)_

### 6. Reference resolution

`populateRefs()` recursively walks the config; any object with a `ref` key is replaced by the merged content of the referenced file (`#/references/<name>` or a relative path under `src/actions-yaml-references/`), with the action's inline keys overriding the reference. Path traversal is rejected (`PathTraversalError` → 400); a missing file is `ReferenceNotFoundError` → 404; a parse failure is `ReferenceParseError` → 500. Nested references resolve. **Implemented (2026-06-07):** nesting is bounded at a **maximum depth of 3** — `populateRefs` tracks the resolution depth and the visited-path set, throwing `ReferenceDepthError` past three levels and `CircularReferenceError` on a revisited path. Shipped references: `gradio-space.yaml` (a `k8s-deployment` that bootstraps `uv`, installs `requirements.txt`, and runs `app.py`) and `huggingface-inference.yaml` (a `k8s-inferenceservice` for vLLM serving).

### 7. Parameter substitution & security (gap)

> **Update (2026-06-07): implemented.** `action-executor.ts` now validates each value against its definition and applies `shq` (shell) / `yamlq` (YAML) escaping. The "As-built" text below is the original pre-hardening state, kept for history.

**As-built:** raw-text regex replace, no quoting, no validation (notes above). A parameter value like `"; rm -rf / #"` or one containing YAML control characters can break out of the intended context.

**Target:**

1. Validate each provided value against its parameter's JSON Schema definition (`type`, `enum` membership, `required`); reject unknown parameter keys.
2. Apply context-appropriate escaping when substituting — `shq` for values landing in shell `script`/`pre`/`post`, `yamlq` for values landing in manifest scalars (`options`, `env`).
3. Perform validation, escaping, **and** parameter resolution entirely in the **web service** (`executeAction`). `k8s-root-actor` is never responsible for parameter handling — it receives an already-resolved `actionConfig` and the request contract between the two services is unchanged.

### 8. Persistence integration (ADR-0012)

Per [ADR-0012](/adr/0012-job-deployment-management-system.md), the three action types collapse into **two ADR-0012 resource kinds**:

| Action `type`          | ADR-0012 kind                         | Table         |
| ---------------------- | ------------------------------------- | ------------- |
| `slurm-job`            | **job** (short-running batch task)    | `jobs`        |
| `k8s-deployment`       | **deployment** (long-running service) | `deployments` |
| `k8s-inferenceservice` | **deployment** (long-running service) | `deployments` |

For now `slurm-job` is the only **job** type; both Kubernetes action types are **deployments**. Action execution is the **creation point** for the corresponding ADR-0012 record. The record schema, secret model, status enums, `gitlab_project_id` keying, and the read/update/list/destroy endpoints are all defined in [DD-0012](/dd/0012-job-deployment-management-system.md) and are not restated here.

> **Update (2026-06-07): implemented (the split moved server-side).** The legacy Valkey helpers (`createJobEntry` / `pushJobLog` / `getRepoJobs`) were removed. `executeAction` no longer creates any record itself — it proxies the resolved action to `k8s-root-actor`, which owns the id+secret, branches on `actionConfig.type` (`slurm-job` → `jobs`, `k8s-deployment`/`k8s-inferenceservice` → `deployments`), and persists the correct ADR-0012 record. The "As-built gap" text below predates this.

**As-built gap:** `services/web/src/utils/actions/action-executor.ts` predates the ADR-0012 job/deployment split — it unconditionally calls `createJobEntry` / `pushJobLog` / `getRepoJobs` (Valkey-backed "job" records) for **every** action type, so a `k8s-deployment` or `k8s-inferenceservice` is wrongly tracked as a job. **Target:** `executeAction` must branch on `actionConfig.type` and create a **job** record for `slurm-job` versus a **deployment** record for `k8s-deployment`/`k8s-inferenceservice`, routing each through the matching ADR-0012 layer rather than the legacy Valkey job helpers.

### 9. Destroy lifecycle

> **Update (2026-06-07): implemented for both kinds.** `k8s-root-actor` exposes `DELETE /jobs/{id}` and `DELETE /deployments/{id}` (DD-0012) with per-type resource teardown; `DeploymentManager.tsx` and `JobManager.tsx` both render working Delete buttons. The "As-built" text below predates this.

**As-built:** no destroy. `DeploymentManager.tsx` renders a hardcoded `disabled` delete button; `k8s-root-actor` exposes no `DELETE`.

**Target:** destroy is per-resource (not a separate action type) and runs through DD-0012's destroy flow and `DELETE` endpoints. The actions-specific obligation is teardown of the exact resources each action type created: for `k8s-deployment` the Deployment + Service + VirtualService + Gateway + PVC, for `k8s-inferenceservice` the InferenceService + VirtualService + Gateway + PVC, for `slurm-job` the Kubernetes Job — deleted by their stored stable names.

### 10. UI-guided creation

Automatic `actions.yaml` generation is scoped to **two repo types only — `model` and `space`** — and is driven entirely by the **web UI** during repository creation. Datasets and generic repos never get an auto-generated `actions.yaml`.

> **Update (2026-06-07): implemented.** `RepositoryForm.tsx` offers the type/format selector and `api_createActionsFile` commits the pre-filled `actions.yaml` on create (gradio → `k8s-deployment`, huggingface → `k8s-inferenceservice`). The "As-built" text below predates this.

**As-built:** the model/space create flows (`services/web/src/app/(auth)/{models,spaces}/new/page.tsx`) only create the repo and a README; **no `actions.yaml` is generated**.

**Target:** the create-model and create-space UIs offer a type/format selector and commit a pre-filled `actions.yaml` based on the choice. The selectable values are intentionally minimal for now:

| Repo type | UI selection (only option today) | Generated action                                                        |
| --------- | -------------------------------- | ----------------------------------------------------------------------- |
| `space`   | space type `gradio`              | a `k8s-deployment` action, reusing `gradio-space.yaml` as the reference |
| `model`   | model format `huggingface`       | a `k8s-inferenceservice` action (vLLM serving the Hugging Face model)   |

Each is a single-choice selector today (`gradio` for spaces, `huggingface` for models) but is modelled as an enumeration so additional space types / model formats can be added later without reworking the flow. The committed `actions.yaml` is immediately runnable, and the user can edit it afterward.

### 11. Parameter input UI

> **Update (2026-06-07): implemented.** `ActionParametersModal.tsx` now renders dedicated controls per type (text, number, checkbox, select, file upload) with default pre-fill and client-side `required`/`enum` validation. The "As-built" text below predates this.

**As-built:** `ActionParametersModal.tsx` renders `type={param.type === 'string' ? 'text' : param.type}`, so only `string` (and partially `number`) produce valid inputs; `boolean`, `enum`, and `file` emit invalid HTML `type` attributes. No required-field enforcement or default pre-fill.

**Target:** dedicated controls derived from each parameter's JSON Schema — text (`string`), number input (`number`/`integer`), toggle/checkbox (`boolean`), select for a constrained `enum`, file upload (`file`) — with default pre-fill and client-side `required`/`enum` validation mirroring the server-side validation from §7.

## Data Model

### Current `ActionConfig` (k8s-root-actor)

```ts
type ActionConfig = {
  type: 'slurm-job' | 'k8s-deployment' | 'k8s-inferenceservice';
  ref?: string;
  repos?: { url: string; dir?: string }[];
  env?: string[];                 // "KEY=value" entries
  pre?: string[];
  post?: string[];
  script: string;
  options?: { cpus?: number; ram?: number; gpus?: number; gpuLabel?: string; port?: number; modelName?: string };
};
```

The web-side `CustomAction`/`ActionParameter` shapes (`services/web/src/utils/actions/action-types.ts`) carry only the UI-relevant fields (`name`, `title`, `label`, `parameters`).

**Target:** a validated schema (Zod) sharing these field names, additionally typing `parameters[*]` as JSON Schema parameter definitions (`type`, `title`, `default`, `required`, `enum`) so both the UI and `executeAction` validate against one source of truth.

Persistent records (`jobs`, `deployments`, `job_logs`, `deployment_logs`) and their status enums are owned by ADR-0012 — see [DD-0012 § Data Model](/dd/0012-job-deployment-management-system.md).

## API Design

### Web service

**Execute an action**

`POST /api/{models|datasets|spaces|repos}/[namespace]/[repo]/action/[branchName]`

Request:

```json
{ "action": "train-model", "parameters": { "learning_rate": "0.001", "epochs": "10" } }
```

Response (proxied from k8s-root-actor):

```json
{ "status": "pending", "message": "Kubernetes job applied for Slurm action", "actionName": "train-model" }
```

Errors: `401` (missing `PRIVATE-TOKEN`), `409` (`A job for this action is already running`), `500` (action not found / file not found / k8s failure).

**Listing actions** is not a standalone endpoint — actions are embedded in the resource-detail GET payload (`GET /api/models/[namespace]/[repo]`, etc.) via `loadActionsYaml`.

### k8s-root-actor

`POST /:repoType/:namespace/:repoId/:branchName/action`

Request:

```json
{
  "actionName": "train-model",
  "actionJobName": "ns-repo-main-train-model-1717459200000",
  "actionConfig": { "type": "slurm-job", "script": "…", "options": { "gpus": 1 } },
  "gitlabToken": "glpat-…"
}
```

Responses:

| Code | Body `status` | When                                                |
| ---- | ------------- | --------------------------------------------------- |
| 200  | `pending`     | Resource applied                                    |
| 409  | `conflict`    | `K8sResourceAlreadyExistsError` (deployment exists) |
| 400  | `error`       | Path traversal in `ref`, or unknown action type     |
| 404  | `error`       | Referenced file not found                           |
| 500  | `error`       | Reference parse failure or apply failure            |

**Target additions:** the action endpoint also creates the persisted record, and destroy is served by dedicated `DELETE` endpoints — both contracts are defined in [DD-0012](/dd/0012-job-deployment-management-system.md).

## Testing Strategy

### Unit

- `populateRefs`: `#/references/` and relative resolution; nested refs up to **depth 3** (and rejection past it, which also covers cycles); `PathTraversalError` on `../` escapes; `ReferenceNotFoundError` / `ReferenceParseError`.
- Parameter validation + escaping (target §7): values are checked against their JSON Schema (`enum` membership rejected when out of range, `required` enforced, `type` coerced/validated); shell/YAML metacharacters in values are neutralized by `shq`/`yamlq`.
- `buildStableActionName` / `sanitizeK8sName`: deterministic, DNS-safe, ≤ name limits.
- Template rendering: each `*.hbs` renders valid YAML for representative contexts.

### Integration (local Minikube)

- Each action type end-to-end: `slurm-job` creates a `batch/v1` Job; `k8s-deployment` creates PVC + Deployment + Service + VirtualService + Gateway and serves at the external URL; `k8s-inferenceservice` creates a KServe InferenceService.
- Re-running a live `k8s-deployment` returns **409**.
- Target: action execution creates the expected ADR-0012 record, and destroy removes exactly the resources the action created (per §9). Record/status assertions belong to DD-0012's test suite.

### Manual acceptance criteria

- A repo with `actions.yaml` shows its actions in `ResourceActions`; a parameterized action opens the modal with correct controls per type; submitting runs the action and the UI redirects to `#jobs` where status updates appear.
- A malicious parameter value cannot alter the executed command or manifest (target).

## Deployment & Rollout

Phased, ordered by risk and by dependency on ADR-0012 landing first:

1. **Security hardening (no new infra):** add `ActionConfig`/parameter schema validation and safe `shq`/`yamlq` substitution in the web service; drop the GitLab PAT `api` scope (mint with `read_repository`/`write_repository` only); enforce the depth-3 reference nesting limit.
2. **Persistence + destroy (requires ADR-0012):** adopt the ADR-0012 record layer for action jobs/deployments; implement per-action resource teardown on destroy; enable the destroy button.
3. **Typed parameter UI:** ship boolean/enum/file controls and client-side validation in `ActionParametersModal`.
4. **UI-guided creation:** generate `actions.yaml` during model and space creation only — a `huggingface` model → `k8s-inferenceservice`, a `gradio` space → `k8s-deployment`.

Each phase is independently shippable; phase 2 must not start before ADR-0012's schema and endpoints exist.

## References

- [ADR-0013: actions.yaml Automation System](/adr/0013-actions-yaml-automation-system.md) — decision and rationale
- [ADR-0012: Job and Deployment Management System](/adr/0012-job-deployment-management-system.md) — persistence decision
- [DD-0012: Job and Deployment Management System — Implementation](/dd/0012-job-deployment-management-system.md) — PostgreSQL schema, secret model, CRUD/destroy endpoints
- [KServe InferenceService CRD](https://kserve.github.io/kserve/0.11/modelserving/v1beta1/inference_service.html)
- [SLURM Documentation](https://slurm.schedmd.com/documentation.html)
- [Handlebars.js](https://handlebarsjs.com/)
- In-repo: `services/web/src/utils/actions/{action-executor,action-loader,action-route-handler}.ts`, `services/web/src/components/{actions/ResourceActions,repo/ActionParametersModal,jobs/JobManager,deployments/DeploymentManager}.tsx`, `services/k8s-root-actor/src/index.ts`, `services/k8s-root-actor/src/lib/action-*.ts`, `services/k8s-root-actor/src/utils/{template-renderer,actions-yaml-references}.ts`, `services/k8s-root-actor/src/templates/`, `services/k8s-root-actor/src/actions-yaml-references/gradio-space.yaml`

<details class="page-source"><summary>Page source</summary>

<a href="/source/docs/content/dd/0013-actions-yaml-automation-system.md.txt">Source snapshot</a> · Revision `f928834c8`.

</details>
