From 969a54a941351224c4e37e1993de1c4f541f07a6 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:21:29 +0000 Subject: [PATCH 1/2] Mirror the factory-files skill from warpdotdev/warp Claude Code loads filesystem skills from this plugin and cannot resolve a bundled_skill_id, so the skill is copied here. It validates against warp-server when reachable and falls back to the schemas beside it otherwise, which keeps a lagging mirror useful and honest about lagging. Mirrors warpdotdev/warp commit 85e89ea. Part of REMOTE-2868. Co-Authored-By: Warp --- plugins/oz-harness-support/README.md | 16 + .../skills/factory-files/SKILL.md | 176 ++ .../factory-files/references/examples.md | 208 +++ .../factory-files/references/scorers.md | 51 + .../factory-files/references/validation.md | 181 ++ .../factory-files/schemas/agent.schema.json | 24 + .../schemas/automation.schema.json | 929 ++++++++++ .../factory-files/schemas/common.schema.json | 190 ++ .../factory-files/schemas/factory.schema.json | 118 ++ .../factory-files/schemas/runner.schema.json | 135 ++ .../factory-files/schemas/scorer.schema.json | 74 + .../scripts/validate_factory_files.py | 1624 +++++++++++++++++ .../tests/test-factory-files.sh | 105 ++ 13 files changed, 3831 insertions(+) create mode 100644 plugins/oz-harness-support/skills/factory-files/SKILL.md create mode 100644 plugins/oz-harness-support/skills/factory-files/references/examples.md create mode 100644 plugins/oz-harness-support/skills/factory-files/references/scorers.md create mode 100644 plugins/oz-harness-support/skills/factory-files/references/validation.md create mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json create mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json create mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json create mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json create mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json create mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json create mode 100755 plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py create mode 100755 plugins/oz-harness-support/tests/test-factory-files.sh diff --git a/plugins/oz-harness-support/README.md b/plugins/oz-harness-support/README.md index f9d461b..498cc0b 100644 --- a/plugins/oz-harness-support/README.md +++ b/plugins/oz-harness-support/README.md @@ -20,6 +20,22 @@ The plugin ships skills the agent uses to talk to the Oz platform: - **oz-notify-user** — send a progress notification to the triggering user - **oz-report-pr** — report a created pull request back to Oz - **oz-upload-file** — upload a local file as a conversation artifact +- **factory-files** — author and validate file-based Warp software factory definitions + +### factory-files is a mirror + +`skills/factory-files` is a byte-for-byte copy of +`resources/bundled/skills/factory-files` in +[warpdotdev/warp](https://github.com/warpdotdev/warp), mirrored at commit +`85e89ea`. Warp bundles that skill for its own clients; Claude Code loads +filesystem skills from this plugin instead, so it is copied here rather than +resolved from a bundle. Change it in `warpdotdev/warp` and re-mirror; edits +made here are lost on the next sync. + +The skill validates against warp-server when it is reachable and falls back to +the schemas bundled beside it otherwise, so a copy that lags the server still +works and says that it lagged. `tests/test-factory-files.sh` checks the copy +arrived complete and runs; its regression corpus lives in Warp. ## Requirements diff --git a/plugins/oz-harness-support/skills/factory-files/SKILL.md b/plugins/oz-harness-support/skills/factory-files/SKILL.md new file mode 100644 index 0000000..c0721a5 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/SKILL.md @@ -0,0 +1,176 @@ +--- +name: factory-files +description: Create and edit file-based Warp software factory definitions, in a repository tree rooted at a factory.yaml. Use when authoring or changing that factory.yaml, Agent, Automation, Scorer, or Runner files under that root, or its factory and agent skill trees, and when fixing Factory file diagnostics. Do not use for agent-definition Markdown that belongs to another tool, for a tree with no factory.yaml, or to operate a live factory or hand work to one through Factory MCP. +--- + +# Factory Files + +A software factory can be defined by files in a repository. This skill covers +authoring and editing those files, and validating them before you open a pull +request. + +warp-server owns the format. It publishes the schema for each version it +supports and validates a tree with the same parser the apply path uses, so ask +it rather than reasoning from a copy that ships inside a Warp release. This +skill bundles schemas and a validator as an offline floor for when the server +cannot be reached, and always says which of the two ran. + +Use this skill for repository files. It is not the skill for operating a live +factory: use `factory-mcp` to send work to a factory, inspect task status, or +pull a task down locally. Playbooks under a factory's own `skills/` directories +tell that factory's agents how to do their job; editing one is a prompt change, +not a schema change, so this skill's rules do not apply to their contents. + +## Locate the Factory root +Every Factory tree is rooted at the directory containing `factory.yaml`. All +paths below are relative to that root. A repository may register a +subdirectory as the root, so find `factory.yaml` rather than assuming the +repository root. Do not follow symlinks while looking: the server parses the +repository tree, where a symlink is stored as its target path rather than its +target's content. + +If there is no `factory.yaml`, this is not a Factory tree and nothing here +applies. `agents//agent.md` and similar paths are also used by other +agent tooling; stop and say so rather than imposing this schema on them. + +``` +factory.yaml required, exactly one +agents//agent.md at least one; exactly one must be MAIN +agents//skills/** skills only that agent can use +automations//automation.md optional +runners/.yaml optional +scorers//scorer.md optional; Markdown body is the rubric +skills/** skills every agent in the factory can use +``` + +Resource names come from the path, never from a field inside the file. Renaming +an agent means moving its directory. + +`automations/.md` is a legacy flat form the parser still accepts. Create +the directory form; when editing an existing flat file, leave it where it is +unless the user asks you to normalize the tree. + +## Before you edit +1. Read the files you are about to change, plus `factory.yaml`, so you can see + what is inherited and what is overridden. +2. Preserve fields and Markdown bodies you were not asked to change. The body + after an Agent's or Automation's closing `---` fence is its prompt; a + Scorer's body is its rubric. Never fold either into frontmatter. +3. Prefer the smallest edit that satisfies the request. + +## Author against the server's schema +Read the tree's `schemaVersion` from `factory.yaml`; a tree that omits it is +`v1alpha1`. Then fetch the schema for that version: + +```bash +curl -s https://app.warp.dev/api/v1/factory-files/schemas +curl -s https://app.warp.dev/api/v1/factory-files/schemas/ +``` + +The registry lists the versions the server supports. The version endpoint +returns every document describing one version, keyed by file name: +`factory.schema.json` for `factory.yaml`, `agent.schema.json`, +`automation.schema.json`, `runner.schema.json` and `scorer.schema.json` for the +corresponding resources, and `common.schema.json` for the definitions they +share. Both endpoints are unauthenticated. They are exact for the version they +describe: an unknown field is an error, and each enumerated value is one the +server accepts today. + +If the server does not publish the declared version, stop. Do not measure the +tree against a version it does not claim to be, and never lower +`schemaVersion` to make a check pass. + +Read `references/examples.md` for worked examples of each resource, and +`references/scorers.md` before writing or changing a Scorer. The field-by-field +catalogue is not duplicated here any more; the fetched schema carries it, with +a description on each field. + +## Validate before opening a pull request +Run the bundled validator with Python 3.8 or newer, using the host's command +(`python3`, `python`, or `py -3`). Quote both paths because an app-bundle path +can contain spaces. + +```bash +python3 "{{skill_dir}}/scripts/validate_factory_files.py" "" +``` + +It asks the server first and falls back to the bundled copy on its own. Add +`--json` for machine-readable output, `--server-root ` to point at a +local, staging, or self-hosted server, and `--offline` to skip the server +deliberately. `WARP_SERVER_ROOT` sets the root too. The validation endpoint is +authenticated and reads `WARP_API_KEY`, which agent sandboxes already carry. + +A non-zero exit means at least one problem; fix every reported problem and +re-run until it is clean. + +If no Python 3 interpreter is available, do not install one or claim the tree +was validated without the user's approval. Check the changed document against +the fetched schema by hand and report that automated validation was +unavailable. + +### Say which validation ran +The validator prints one of two sentences. Repeat it; do not paraphrase it +away. + +- Server: the tree went through warp-server's own parser for its declared + version. State-dependent apply checks still did not run. +- Offline: the server was unreachable, unauthenticated, or answered unusably, + so the bundled copy ran instead. That copy can be older than the server, so a + pass is weaker evidence than it looks. + +Never present an offline pass as a server verdict, and never treat a successful +schema fetch as validation on its own. + +Neither path resolves server state. Model IDs, environment IDs, secret names, +runner names, Scorer model IDs, MCP server IDs, integration availability, and +the values of Linear and Slack name aliases are all checked when the plan is +applied. The server response lists what it did not check; report that +distinction rather than claiming a tree is fully verified. + +When the Factory is already registered, a server plan remains the strongest +available check. See `references/validation.md` for diagnostic codes and how to +read them. + +## Rules that are easy to get wrong +- Exactly one agent declares `agentType: MAIN` (or `FOREMAN`, its canonical + spelling). Zero or two is an error. +- `model` and `harness` are mutually exclusive everywhere. `model: ` is + shorthand for the Oz harness. +- `agentDefaults` must declare one of them; agents and automations may declare + neither and inherit. +- Declaring `secrets` or `mcpServers` at agent or automation level replaces the + inherited value; it does not merge. +- An automation needs at least one trigger, and every trigger needs `provider` + and `event`. +- A `schedule.cron_fired` trigger needs either an inline `schedule.cron` or a + non-empty `filter.schedule_ids`, and never both. +- Linux runners require `platform.linux.dockerImage`. A runner with no + `platform` section defaults to Linux and will fail for that reason. +- Trigger filter keys depend on the `(provider, event)` pair. Some fields have + a friendlier authoring spelling that the server rewrites for you: GitHub + `baseBranches` and `prNumbers`, Linear `teams`, `projects`, `states` and + `issues`, and Slack `channels`, `users` and `itemUsers`. Each stands in for + its canonical key, and declaring both is an error. The Linear and Slack ones + name objects the server looks up at apply time, so they take a plain list of + names rather than an `in`/`not_in` matcher. + +## When the bundled copy and the server disagree +The server is right. The bundled schemas and validator ship inside your Warp +version, so they can be older than the server the Factory syncs against, and +they are deliberately permissive to avoid rejecting what a newer server +accepts. + +- Never delete, rename, or rewrite a field only because the offline validator + calls it unknown. On a file you did not author, that is at least as likely to + be a newer field as a mistake. Leave it, and say the bundled copy may be + behind. +- Treat unknown-field reports on your own new edits as real. You are the one + who just introduced the field. +- If the offline validator reports that it does not describe the tree's + `schemaVersion`, it stopped rather than applying `v1alpha1` rules to a format + it does not know. Validate against the server instead. + +If you are editing the bundled schemas themselves rather than a Factory tree, +their openness is deliberate and load-bearing, and it is not the policy the +server's own schemas follow. Read the "If you are changing these schemas" +section of `references/validation.md` before tightening anything. diff --git a/plugins/oz-harness-support/skills/factory-files/references/examples.md b/plugins/oz-harness-support/skills/factory-files/references/examples.md new file mode 100644 index 0000000..7917661 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/references/examples.md @@ -0,0 +1,208 @@ +# Examples +Every value below is a placeholder. Never copy IDs, secret names, ARNs, +project numbers, or images from another factory into a new one. + +## Minimal factory +``` +factory.yaml +agents/foreman/agent.md +``` + +`factory.yaml`: + +```yaml +schemaVersion: v1alpha1 +name: platform-ops +description: Keeps the platform repositories healthy. +repositories: + - owner: acme + name: platform +agentDefaults: + model: auto +``` + +`agents/foreman/agent.md`: + +```markdown +--- +description: Entry point for platform work. +agentType: MAIN +--- +You are the platform foreman. Triage incoming work, decide whether it needs a +spec, and delegate implementation. +``` + +## Factory with secrets, MCP, and integrations +```yaml +schemaVersion: v1alpha1 +name: platform-ops +alias: Platform Ops +credentialStrategy: EXECUTOR +repositories: + - owner: acme + name: platform + - owner: acme + name: platform-config +secrets: + - DEPLOY_TOKEN +mcpServers: + github: + warpId: mcp_placeholder_github +integrations: + - type: linear + - type: slack +cloudProviders: + gcp: + projectNumber: "000000000000" + workloadIdentityFederationPoolId: example-pool + workloadIdentityFederationProviderId: example-provider + serviceAccountEmail: factory@example.iam.gserviceaccount.com +agentDefaults: + model: auto + runner: linux-standard + secrets: + - DEPLOY_TOKEN +``` + +## Agent on a non-Oz harness +```markdown +--- +description: Implements approved specs. +agentType: IMPLEMENT +harness: + type: claude + model: opus + reasoningLevel: high + auth: + source: managedSecret + secretName: ANTHROPIC_API_KEY +runner: linux-standard +--- +Implement the approved spec. Keep the change scoped to what the spec describes. +``` + +The Oz shorthand is a single field, and is mutually exclusive with `harness`: + +```markdown +--- +agentType: REVIEW +model: auto +--- +Review the change against the repository's conventions. +``` + +## Agent that clears an inherited value +```markdown +--- +description: Runs on the shared workspace default host. +workerHost: null +secrets: [] +--- +Investigate the failure and report what you find. +``` + +`workerHost: null` clears an inherited host. `secrets: []` replaces the +inherited list with nothing; it does not merge. + +## Event-driven automation +`automations/pr-review/automation.md`: + +```markdown +--- +agent: reviewer +triggers: + - provider: github + event: pull_request_opened + filter: + repos: [acme/platform] + base_branches: [main] + labels: + not_in: [wip] + - provider: github + event: pull_request_synchronized + filter: + repos: [acme/platform] +--- +Review the pull request that triggered this run. +``` + +## Scheduled automation +```markdown +--- +enabled: true +triggers: + - provider: schedule + event: cron_fired + schedule: + name: nightly-sweep + cron: 0 3 * * * +--- +Sweep for stale branches and open a cleanup pull request if any are found. +``` + +## Linux runner +`runners/linux-standard.yaml`: + +```yaml +description: Standard Linux build runner. +setupCommands: + - apt-get update -y + - apt-get install -y build-essential +instanceShape: + vcpus: 4 + memoryGb: 8 +platform: + os: linux + arch: x86_64 + linux: + dockerImage: ubuntu:24.04 +``` + +## macOS runner +`runners/macos-standard.yaml`: + +```yaml +description: macOS runner for Apple platform builds. +instanceShape: + vcpus: 6 + memoryGb: 14 +platform: + os: macos + arch: aarch64 + mac: + version: "15" +``` +## Scorer +`scorers/tests-run/scorer.md`: + +```markdown +--- +description: Checks whether implementation runs include test evidence. +agents: + - implementer +labels: + - value: tests_run + score: 1 + - value: tests_skipped + score: 0 +passingScore: 1 +samplingRate: 25 +model: claude-4-5-haiku +--- +Evaluate whether the agent ran the relevant tests before finishing. Return +`tests_run` when the transcript contains the command and result; otherwise +return `tests_skipped`. +``` + +## Scoped skills +A skill under `skills/` is available to every agent in the factory. A skill +under `agents//skills/` is available only to that agent. + +``` +skills/release-checklist/SKILL.md every agent +agents/reviewer/skills/review-style/SKILL.md the reviewer agent only +``` + +These are agent playbooks, not Factory schema. Moving one between the two +locations changes who can use it, so do not relocate a skill unless that is +what was asked for. diff --git a/plugins/oz-harness-support/skills/factory-files/references/scorers.md b/plugins/oz-harness-support/skills/factory-files/references/scorers.md new file mode 100644 index 0000000..7a6ae38 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/references/scorers.md @@ -0,0 +1,51 @@ +# File-defined Scorers + +A Scorer lives at `scorers//scorer.md`. Its directory provides its name, +YAML frontmatter defines the classification contract, and the non-empty +Markdown body is the rubric evaluated against eligible runs. + +```markdown +--- +description: Checks whether implementation runs include test evidence. +agents: + - implementer +labels: + - value: tests_run + description: The transcript contains a test command and its result. + score: 1 + - value: tests_partial + description: Only a relevant subset of tests ran. + score: 0.5 + - value: tests_skipped + score: 0 +passingScore: 1 +samplingRate: 25 +model: claude-4-5-haiku +selfImprovement: true +--- +Evaluate whether the agent ran the repository's relevant tests before +finishing. Return exactly one declared label. +``` + +## Fields + +- `agents` — required non-empty list of Agent names declared in this Factory. + Names are trimmed and must be unique. +- `enabled` — optional boolean, default true. Use `enabled: false` rather than + a zero sampling rate to pause scoring. +- `output` — optional output form. `classification` is the current known form; + preserve newer values for forward compatibility. +- `labels` — required non-empty list of classifications; the current server + accepts at most 20. Each label requires a non-empty `value` and a numeric + `score` from 0 through 1; `description` is optional. Label values are + trimmed and unique. +- `passingScore` — required numeric threshold from 0 through 1. At least one + label must score at or above it, and at least one below it. +- `samplingRate` — optional percentage, default 25. Values are rounded to two + decimal places and must resolve to 0.01–100. +- `model` — required model ID. The server validates availability. +- `selfImprovement` — optional boolean, default false. + +The Markdown body must not be empty. Scorer fields are forward-compatible: +preserve unknown fields rather than deleting them to satisfy an older bundled +schema. diff --git a/plugins/oz-harness-support/skills/factory-files/references/validation.md b/plugins/oz-harness-support/skills/factory-files/references/validation.md new file mode 100644 index 0000000..9aeab41 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/references/validation.md @@ -0,0 +1,181 @@ +# Validating and reading diagnostics + +## Layers of validation +1. **The server's parser** (`logic/factoryfile` in `warp-server`) is the + authority. `POST /api/v1/factory-files/validate` runs it over a tree you + submit as paths and content, and adds the state-independent rules the apply + path enforces next: runner platforms and instance shapes, and trigger filter + keys and matchers. Its diagnostics carry `FF_*` codes with a path, line, and + column. +2. **The bundled validator** (`scripts/validate_factory_files.py`) is the + offline floor. It checks the files against the JSON Schemas shipped beside + it plus the tree-level rules, needs nothing but Python 3, and runs when the + server cannot be reached. It can be older than the server. +3. **Resolution and apply** validate everything that needs server state: model + IDs, environment IDs, secret names, runner names, MCP server IDs, + integration providers, harness model catalogues, worker-host entitlement, + and the values of Linear and Slack name aliases. + +A clean result from either of the first two means the files pass the structural +and state-independent semantic checks. It does not mean the plan will apply. +The server response lists the checks it did not run; say so rather than +overstating what was checked. + +## Running the validator +The script lives at `scripts/validate_factory_files.py` inside this skill's +directory; `SKILL.md` shows its resolved path. + +```bash +python3 "/scripts/validate_factory_files.py" "" +python3 "/scripts/validate_factory_files.py" "" --json +python3 "/scripts/validate_factory_files.py" "" --offline +``` + +It reads the tree's `schemaVersion`, confirms the server publishes it, and +submits the tree; only if that fails does it fall back. `--server-root ` +or `WARP_SERVER_ROOT` selects a local, staging, or self-hosted server; +`WARP_API_KEY` authenticates the validation endpoint. `--offline` skips the +server deliberately. + +Every run ends with a sentence naming the path that ran, and `--json` carries +the same fact in `validated_with`. Repeat it. An offline pass is not a server +verdict, and a successful schema fetch is not validation. + +A version the server does not publish stops the run rather than being measured +against another version's rules. Correct the version; never lower it to make a +check pass. + +Use Python 3.8 or newer via the host's command (`python3`, `python`, or `py -3`). If none is +available, do not install an interpreter or claim automated validation without +the user's approval; inspect the changed document against its JSON Schema and +report the validation gap. + +Exit code 0 means no problems. Each problem reports the file, the field path, +and what is wrong. Fix them all and re-run; do not stop at the first one, since +one wrong field often produces several messages. +The bundled reader handles the canonical YAML forms this skill emits, not every +piece of YAML syntax accepted by `gopkg.in/yaml.v3`. If it cannot read an +existing file that the server accepts, do not normalize or rewrite the file +merely for the reader; report that local validation was unavailable and use a +server plan when possible. + +A resource file that is a symlink is reported and not read. The server parses +the repository tree, where a symlink is stored as its target path rather than +its target's content, so it never follows one either; a Factory resource has to +be a real file. Reading the target locally would also let a repository aim a +resource at any readable path on the machine. + +The schemas are ordinary JSON Schema 2020-12 documents, so any standard +validator works too if the tree is already converted to JSON. That applies to +the documents the server serves as well; those are exact for the version they +describe. `x-warp-*` annotations carry constraints JSON Schema cannot express +portably, such as trimmed Unicode alias rules and the power-of-two Linux +compute sizes; only a Warp validator enforces those annotations. + +## Deferred resolutions +A server response can carry `deferred_resolutions` alongside its diagnostics. A +deferred entry is not a problem: it names an authored value the endpoint +deliberately did not prove, because proving it needs provider state. Linear and +Slack name aliases are the current case — the endpoint checks that `teams` or +`channels` is a list of non-empty names applicable to that event, and leaves +whether those names exist to apply time. + +## Diagnostic codes +The server reports these from the validation endpoint and when a plan is run +against a registered Factory. + +- `FF_MISSING_FACTORY` — no `factory.yaml` at the Factory root. +- `FF_UNSUPPORTED_VERSION` — `schemaVersion` names no registered tree adapter. + The bundled validator reports an unrecognized version and stops rather than + applying v1alpha1 rules to a tree it does not describe. +- `FF_UNSUPPORTED_PATH` — a file that resembles an Agent, Automation, Runner, + or Scorer resource is at a non-canonical path. Other unrelated files under + those directories are intentionally ignored. +- `FF_DUPLICATE_PATH` — the same resource name is declared twice, most often an + automation declared in both the flat and directory forms. +- `FF_INVALID_DOCUMENT` — a file is empty or its root is not a YAML mapping. +- `FF_MALFORMED_FRONTMATTER` — a Markdown resource is missing an opening or + closing `---` fence. +- `FF_INVALID_YAML` — the YAML could not be parsed. +- `FF_DUPLICATE_KEY` — a mapping repeats a key. Also reported when two agents + declare `MAIN`/`FOREMAN`, or a secret is listed twice. +- `FF_ANCHOR`, `FF_ALIAS`, `FF_TAG` — YAML anchors, aliases, and explicit tags + are not permitted. +- `FF_UNKNOWN_FIELD` — a field the schema does not define. Check spelling and + the field reference; do not add the field to the schema to make it pass. +- `FF_MISSING_REQUIRED` — a required field is absent or empty. +- `FF_TYPE_MISMATCH` — a value has the wrong YAML type. +- `FF_INVALID_VALUE` — a value violates a format or exclusivity rule, such as + declaring both `model` and `harness`, or an alias with disallowed characters. +- `FF_INVALID_REFERENCE` — a named reference does not resolve, such as an + Automation or Scorer naming an Agent the tree does not declare, or an + unknown current `agentType` or `credentialStrategy`. +- `FF_INVALID_MCP` — an MCP entry is not exactly a non-empty `warpId`. +- `FF_INVALID_TRIGGER` — a trigger is structurally wrong, such as an inline + schedule on a non-schedule trigger, or a `schedule.cron_fired` trigger that + declares both or neither of `schedule.cron` and `filter.schedule_ids`. +- `FF_INVALID_EVENT`, `FF_INVALID_FILTER` — the event is unknown, or a filter + value is outside its valid domain. + +The bundled schemas do not reproduce every catalogue rejection above. Unknown +properties, agent types, credential strategies, harnesses and their per-harness +capabilities, integration types, trigger providers and events, runner platform +values, Scorer output forms, and server-tunable limits such as the Scorer label +cap are all preserved so an older client does not reject source accepted by a +newer server. A server plan is authoritative. + +Filter keys are the one catalogue still checked, because a misspelled key is a +common mistake that otherwise survives until apply. The check applies only when +both the provider and the event are ones these schemas know; a newer provider, +or a newer event on a known provider, leaves its filter unconstrained. + +What the bundled validator still refuses is what stays wrong under any of those +changes: malformed YAML and frontmatter, missing required fields, values of the +wrong type, references to Agents the tree does not declare, more or fewer than +one `MAIN`/`FOREMAN` Agent, duplicate resource names and labels, an empty +Scorer rubric, a label set that cannot both pass and fail, and filters that can +never match. + +## If you are changing these schemas +The permissiveness above is load-bearing, not an unfinished edge. These files +ship inside a Warp release and are routinely older than the `warp-server` they +run against, so closing them back up would reject configuration a newer server +accepts and push agents to delete working fields. + +When the format gains a value, add it to the relevant `x-warp-known-values` or +`x-warp-known-max-items` annotation. Do not turn an annotation back into +`enum`, `const`, `maxItems`, or `additionalProperties: false`. The regression +corpus in `script/test_factory_files_skill.py` asserts several of these +tolerances on purpose; if one starts failing, a schema was tightened. + +## Fixing a diagnostic +Change the file the diagnostic names, at the field it names. Do not silence a +diagnostic by deleting the resource, loosening the schema, or moving a file to +a path the parser ignores. + +If a diagnostic contradicts these references, the server is right. Say that the +bundled schemas look stale and, where you can, point at what changed in +`logic/factoryfile`. + +## Version skew +The schemas ship inside the Warp version running them, not from the server, so +they can lag the server that a Factory actually syncs against. Cloud agent runs +track releases closely; an installed desktop client can be much older. + +The asymmetry matters when reading an `unknown field` report: + +- On a field you just wrote, it is almost certainly a mistake. Fix it. +- On a field that was already in the file, it may be a newer field your copy of + the schemas does not know. Leave it alone and report the possibility. Removing + it would silently drop working configuration. + +## Checking against the parser directly +When `warp-server` is checked out locally, its parser tests are the closest +thing to ground truth. Run them from that checkout, not from the Factory +repository: + +```bash +go test ./logic/factoryfile +``` + +Fixtures under `logic/factoryfile/testdata` show accepted and rejected trees. diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json new file mode 100644 index 0000000..6bf0b8e --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "agent.schema.json", + "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", + "title": "agents//agent.md frontmatter (v1alpha1)", + "description": "YAML frontmatter of an Agent file. The Agent's name comes from its directory, never from a field. The Markdown body after the closing fence is the Agent's prompt.", + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": ["string", "null"] + }, + "agentType": { "$ref": "common.schema.json#/$defs/agentType" }, + "credentialStrategy": { "$ref": "common.schema.json#/$defs/credentialStrategy" }, + "model": { "$ref": "common.schema.json#/$defs/ozModelOverride" }, + "harness": { "$ref": "common.schema.json#/$defs/harnessOverride" }, + "runner": { "$ref": "common.schema.json#/$defs/runnerRef" }, + "environmentId": { "$ref": "common.schema.json#/$defs/environmentId" }, + "secrets": { "$ref": "common.schema.json#/$defs/secrets" }, + "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, + "workerHost": { "$ref": "common.schema.json#/$defs/workerHost" } + }, + "allOf": [{ "$ref": "common.schema.json#/$defs/modelXorHarnessOptional" }] +} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json new file mode 100644 index 0000000..b83c738 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json @@ -0,0 +1,929 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "automation.schema.json", + "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", + "title": "automations//automation.md frontmatter (v1alpha1)", + "description": "YAML frontmatter of an Automation file. The Automation's name comes from its directory, never from a field. The Markdown body after the closing fence is the run prompt.", + "type": "object", + "required": ["triggers"], + "additionalProperties": true, + "properties": { + "enabled": { + "type": "boolean", + "description": "Defaults to true when omitted." + }, + "agent": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "description": "Name of a declared Agent. Defaults to the MAIN/FOREMAN Agent when omitted." + }, + "model": { "$ref": "common.schema.json#/$defs/ozModelOverride" }, + "harness": { "$ref": "common.schema.json#/$defs/harnessOverride" }, + "runner": { "$ref": "common.schema.json#/$defs/runnerRef" }, + "environmentId": { "$ref": "common.schema.json#/$defs/environmentId" }, + "secrets": { "$ref": "common.schema.json#/$defs/secrets" }, + "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, + "workerHost": { "$ref": "common.schema.json#/$defs/workerHost" }, + "triggers": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/trigger" } + } + }, + "allOf": [{ "$ref": "common.schema.json#/$defs/modelXorHarnessOptional" }], + "$defs": { + "stringMatcher": { + "description": "A bare array is sugar for {in: [...]}. Within a field, in matches ANY-of and not_in excludes ANY-of. Null and an empty matcher impose nothing.", + "anyOf": [ + { "type": "null" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "in": { "type": "array", "items": { "type": "string" } }, + "not_in": { "type": "array", "items": { "type": "string" } } + } + } + ] + }, + "intMatcher": { + "anyOf": [ + { "type": "null" }, + { "type": "array", "items": { "type": "integer" } }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "in": { "type": "array", "items": { "type": "integer" } }, + "not_in": { "type": "array", "items": { "type": "integer" } } + } + } + ] + }, + "scheduleIdsMatcher": { + "description": "schedule_ids supports only the in operator: an exclusion would match every other schedule in scope.", + "anyOf": [ + { "type": "null" }, + { "type": "array", "items": { "type": "string" } }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "in": { "type": "array", "items": { "type": "string" } } + } + } + ] + }, + "stringAliasList": { + "description": "A provider name alias takes a bare list of names, never a matcher object: the server resolves the names to durable IDs before the filter is canonicalized.", + "type": "array", + "items": { "$ref": "common.schema.json#/$defs/nonEmptyString" } + }, + "intAliasList": { + "description": "A provider number alias takes a bare list of positive numbers, never a matcher object.", + "type": "array", + "items": { "type": "integer", "minimum": 1 } + }, + "nonEmptyScheduleIds": { + "type": "object", + "required": ["filter"], + "properties": { + "filter": { + "type": "object", + "required": ["schedule_ids"], + "properties": { + "schedule_ids": { + "anyOf": [ + { "type": "array", "minItems": 1 }, + { + "type": "object", + "required": ["in"], + "properties": { "in": { "type": "array", "minItems": 1 } } + } + ] + } + } + } + } + }, + "inlineSchedule": { + "type": "object", + "required": ["cron"], + "additionalProperties": true, + "description": "A cron schedule declared inline on a schedule.cron_fired trigger. Only valid on that kind, and mutually exclusive with filter.schedule_ids.", + "properties": { + "name": { + "type": ["string", "null"], + "description": "Stable identity of this declaration within the Automation. At most one inline schedule may omit name. Changing name replaces the schedule; changing only cron updates it in place." + }, + "cron": { + "type": "string", + "pattern": "^\\s*(@(annually|yearly|monthly|weekly|daily|midnight|hourly)|@every\\s+\\S+|(\\S+\\s+){4}\\S+)\\s*$", + "description": "Standard five-field cron expression or a descriptor (@daily, @hourly, @every 1h). Always interpreted in UTC: a CRON_TZ= or TZ= prefix and the six-field seconds form are both rejected." + } + } + }, + "trigger": { + "type": "object", + "required": ["provider", "event"], + "additionalProperties": true, + "properties": { + "provider": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["github", "gitlab", "linear", "jira", "slack", "schedule", "factory"] + }, + "event": { "type": "string" }, + "filter": { + "type": "object", + "description": "Filter fields combine with AND. An absent field is a wildcard. The accepted keys depend on the (provider, event) pair." + }, + "schedule": { "$ref": "#/$defs/inlineSchedule" } + }, + "allOf": [ + { + "if": { + "required": ["provider"], + "properties": { "provider": { "const": "github" } } + }, + "then": { + "properties": { + "event": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": [ + "push", + "issue_created", + "issue_labeled", + "issue_mentioned", + "issue_assigned", + "pull_request_opened", + "pull_request_ready", + "pull_request_closed", + "pull_request_merged", + "pull_request_labeled", + "pull_request_synchronized", + "pull_request_reopened", + "pull_request_mentioned", + "pull_request_assigned", + "pull_request_review_requested", + "pull_request_review_submitted", + "check_suite_completed", + "check_suite_rerequested", + "check_run_rerequested", + "workflow_run_completed" + ] + } + } + } + }, + { + "if": { + "required": ["provider"], + "properties": { "provider": { "const": "gitlab" } } + }, + "then": { + "properties": { + "event": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["merge_request", "bot_mentioned"] + } + } + } + }, + { + "if": { + "required": ["provider"], + "properties": { "provider": { "const": "factory" } } + }, + "then": { + "properties": { + "event": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["work_item_stage_changed"] + } + } + } + }, + { + "if": { + "required": ["provider"], + "properties": { "provider": { "const": "linear" } } + }, + "then": { + "properties": { + "event": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": [ + "issue_created", + "issue_labeled", + "issue_state_changed", + "issue_assigned", + "comment_created", + "agent_session_created" + ] + } + } + } + }, + { + "if": { + "required": ["provider"], + "properties": { "provider": { "const": "jira" } } + }, + "then": { + "properties": { + "event": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": [ + "issue_created", + "issue_labeled", + "status_changed", + "agent_session_created" + ] + } + } + } + }, + { + "if": { + "required": ["provider"], + "properties": { "provider": { "const": "slack" } } + }, + "then": { + "properties": { + "event": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": [ + "app_mention", + "message_dm", + "message_im", + "message_mpim", + "message_posted", + "reaction_added", + "member_joined_channel" + ] + } + } + } + }, + { + "if": { + "required": ["provider"], + "properties": { "provider": { "const": "schedule" } } + }, + "then": { + "properties": { + "event": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["cron_fired"] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "const": "push" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "branches": { "$ref": "#/$defs/stringMatcher" }, + "paths": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "enum": ["issue_created", "issue_labeled"] } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" }, + "assignees": { "$ref": "#/$defs/stringMatcher" }, + "authors": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { + "enum": [ + "pull_request_opened", + "pull_request_ready", + "pull_request_closed", + "pull_request_merged", + "pull_request_labeled", + "pull_request_synchronized", + "pull_request_reopened" + ] + } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "baseBranches": { + "$ref": "#/$defs/stringMatcher", + "description": "Authoring alias for base_branches; the two are mutually exclusive." + }, + "prNumbers": { + "$ref": "#/$defs/intMatcher", + "description": "Authoring alias for pr_numbers; the two are mutually exclusive." + }, + "repos": { "$ref": "#/$defs/stringMatcher" }, + "base_branches": { "$ref": "#/$defs/stringMatcher" }, + "pr_numbers": { "$ref": "#/$defs/intMatcher" }, + "paths": { "$ref": "#/$defs/stringMatcher" }, + "assignees": { "$ref": "#/$defs/stringMatcher" }, + "authors": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" } + }, + "allOf": [ + { "not": { "required": ["baseBranches", "base_branches"] } }, + { "not": { "required": ["prNumbers", "pr_numbers"] } } + ] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "enum": ["issue_mentioned", "pull_request_mentioned"] } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "mentioned": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "enum": ["issue_assigned", "pull_request_assigned"] } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "assignees": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "const": "pull_request_review_requested" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "reviewers": { "$ref": "#/$defs/stringMatcher" }, + "reviewer_teams": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "const": "pull_request_review_submitted" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "mentioned": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" }, + "review_states": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "const": "check_suite_completed" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "conclusions": { "$ref": "#/$defs/stringMatcher" }, + "branches": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" }, + "authors": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { "const": "workflow_run_completed" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "conclusions": { "$ref": "#/$defs/stringMatcher" }, + "branches": { "$ref": "#/$defs/stringMatcher" }, + "workflows": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" }, + "authors": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "github" }, + "event": { + "enum": ["check_run_rerequested", "check_suite_rerequested"] + } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "gitlab" }, + "event": { "const": "merge_request" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "actions": { "$ref": "#/$defs/stringMatcher" }, + "base_branches": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "gitlab" }, + "event": { "const": "bot_mentioned" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "repos": { "$ref": "#/$defs/stringMatcher" }, + "mentioned": { + "$ref": "#/$defs/stringMatcher", + "description": "Server-seeded. Declaring it has no effect." + } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "factory" }, + "event": { "const": "work_item_stage_changed" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "stages": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "linear" }, + "event": { + "enum": [ + "issue_created", + "issue_labeled", + "issue_state_changed", + "issue_assigned" + ] + } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "teams": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for team_ids; the two are mutually exclusive." + }, + "projects": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for project_ids; the two are mutually exclusive." + }, + "states": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for state_ids; the two are mutually exclusive." + }, + "team_ids": { "$ref": "#/$defs/stringMatcher" }, + "project_ids": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" }, + "state_ids": { "$ref": "#/$defs/stringMatcher" }, + "assignee_ids": { "$ref": "#/$defs/stringMatcher" }, + "mentioned_user_ids": { "$ref": "#/$defs/stringMatcher" }, + "creator_ids": { "$ref": "#/$defs/stringMatcher" } + }, + "allOf": [ + { "not": { "required": ["team_ids", "teams"] } }, + { "not": { "required": ["project_ids", "projects"] } }, + { "not": { "required": ["state_ids", "states"] } } + ] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "linear" }, + "event": { "const": "comment_created" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "teams": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for team_ids; the two are mutually exclusive." + }, + "projects": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for project_ids; the two are mutually exclusive." + }, + "states": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for state_ids; the two are mutually exclusive." + }, + "issues": { + "$ref": "#/$defs/intAliasList", + "description": "Authoring alias for issue_ids; the two are mutually exclusive." + }, + "team_ids": { "$ref": "#/$defs/stringMatcher" }, + "project_ids": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" }, + "state_ids": { "$ref": "#/$defs/stringMatcher" }, + "issue_ids": { "$ref": "#/$defs/stringMatcher" }, + "mentioned_user_ids": { "$ref": "#/$defs/stringMatcher" }, + "creator_ids": { "$ref": "#/$defs/stringMatcher" } + }, + "allOf": [ + { "not": { "required": ["team_ids", "teams"] } }, + { "not": { "required": ["project_ids", "projects"] } }, + { "not": { "required": ["state_ids", "states"] } }, + { "not": { "required": ["issue_ids", "issues"] } } + ] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "linear" }, + "event": { "const": "agent_session_created" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "teams": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for team_ids; the two are mutually exclusive." + }, + "team_ids": { "$ref": "#/$defs/stringMatcher" }, + "creator_ids": { "$ref": "#/$defs/stringMatcher" }, + "keywords": { "$ref": "#/$defs/stringMatcher" } + }, + "allOf": [ + { "not": { "required": ["team_ids", "teams"] } } + ] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "jira" }, + "event": { "enum": ["issue_created", "issue_labeled"] } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "project_keys": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "jira" }, + "event": { "const": "status_changed" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "project_keys": { "$ref": "#/$defs/stringMatcher" }, + "status_ids": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "jira" }, + "event": { "const": "agent_session_created" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "project_keys": { "$ref": "#/$defs/stringMatcher" }, + "labels": { "$ref": "#/$defs/stringMatcher" }, + "keywords": { "$ref": "#/$defs/stringMatcher" } + } + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "slack" }, + "event": { + "enum": [ + "app_mention", + "message_dm", + "message_im", + "message_mpim", + "message_posted" + ] + } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "channels": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for channel_ids; the two are mutually exclusive." + }, + "users": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for user_ids; the two are mutually exclusive." + }, + "channel_ids": { "$ref": "#/$defs/stringMatcher" }, + "user_ids": { "$ref": "#/$defs/stringMatcher" }, + "keywords": { "$ref": "#/$defs/stringMatcher" } + }, + "allOf": [ + { "not": { "required": ["channel_ids", "channels"] } }, + { "not": { "required": ["user_ids", "users"] } } + ] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "slack" }, + "event": { "const": "reaction_added" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "channels": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for channel_ids; the two are mutually exclusive." + }, + "users": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for user_ids; the two are mutually exclusive." + }, + "itemUsers": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for item_user_ids; the two are mutually exclusive." + }, + "channel_ids": { "$ref": "#/$defs/stringMatcher" }, + "user_ids": { "$ref": "#/$defs/stringMatcher" }, + "emojis": { "$ref": "#/$defs/stringMatcher" }, + "keywords": { "$ref": "#/$defs/stringMatcher" }, + "item_user_ids": { "$ref": "#/$defs/stringMatcher" } + }, + "allOf": [ + { "not": { "required": ["channel_ids", "channels"] } }, + { "not": { "required": ["user_ids", "users"] } }, + { "not": { "required": ["itemUsers", "item_user_ids"] } } + ] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "slack" }, + "event": { "const": "member_joined_channel" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "channels": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for channel_ids; the two are mutually exclusive." + }, + "users": { + "$ref": "#/$defs/stringAliasList", + "description": "Authoring alias for user_ids; the two are mutually exclusive." + }, + "channel_ids": { "$ref": "#/$defs/stringMatcher" }, + "user_ids": { "$ref": "#/$defs/stringMatcher" } + }, + "allOf": [ + { "not": { "required": ["channel_ids", "channels"] } }, + { "not": { "required": ["user_ids", "users"] } } + ] + } + } + } + }, + { + "if": { + "required": ["provider", "event"], + "properties": { + "provider": { "const": "schedule" }, + "event": { "const": "cron_fired" } + } + }, + "then": { + "properties": { + "filter": { + "additionalProperties": false, + "properties": { + "schedule_ids": { "$ref": "#/$defs/scheduleIdsMatcher" } + } + } + }, + "oneOf": [ + { "required": ["schedule"] }, + { "$ref": "#/$defs/nonEmptyScheduleIds" } + ] + } + }, + { + "if": { + "not": { + "allOf": [ + { + "required": ["provider"], + "properties": { "provider": { "const": "schedule" } } + }, + { + "required": ["event"], + "properties": { "event": { "const": "cron_fired" } } + } + ] + } + }, + "then": { "not": { "required": ["schedule"] } } + } + ] + } + } +} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json new file mode 100644 index 0000000..d794828 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "common.schema.json", + "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", + "title": "Shared Factory file definitions (v1alpha1)", + "description": "Definitions shared by factory.yaml, Agent, Automation, Scorer, and Runner documents.", + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1, + "pattern": "\\S", + "description": "A string that is non-empty after trimming surrounding whitespace." + }, + "nullableNonEmptyString": { + "type": ["string", "null"], + "if": { "type": "string" }, + "then": { + "minLength": 1, + "pattern": "\\S" + }, + "description": "A non-empty string override, or null to inherit." + }, + "clearableString": { + "type": ["string", "null"], + "description": "A string override. Null or empty explicitly clears an inherited value; omitting the field inherits it." + }, + "alias": { + "type": ["string", "null"], + "description": "Factory display handle, used as the @-mention name on integrated platforms. Letters, digits, spaces, '-', '_', and '.' only, at most 60 characters. Case is preserved; uniqueness is compared case-insensitively across the workspace.", + "x-warp-character-class": "unicode-letters-numbers-space-dot-underscore-hyphen", + "x-warp-max-trimmed-runes": 60 + }, + "credentialStrategy": { + "$ref": "#/$defs/nullableNonEmptyString", + "x-warp-known-values": ["EXECUTOR", "CREATOR"], + "description": "Whose credentials a run executes with. Omit to leave the value already stored on the server untouched." + }, + "agentType": { + "$ref": "#/$defs/nullableNonEmptyString", + "x-warp-known-values": ["CUSTOM", "MAIN", "FOREMAN", "TRIAGE", "SPEC", "IMPLEMENT", "REVIEW", "VERIFY"], + "description": "MAIN is an authoring alias for FOREMAN. Exactly one Agent in the tree must declare MAIN or FOREMAN. Omitting agentType resolves to CUSTOM." + }, + "secretName": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of a managed secret already registered for the team." + }, + "secrets": { + "type": "array", + "items": { "$ref": "#/$defs/secretName" }, + "description": "Managed secret names. Declaring the field at Agent or Automation level replaces the inherited list rather than adding to it." + }, + "uniqueSecrets": { + "type": "array", + "items": { "$ref": "#/$defs/secretName" }, + "uniqueItems": true, + "description": "Managed secret names. Duplicates are rejected in factory.yaml." + }, + "mcpServers": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/nonEmptyString" }, + "additionalProperties": { + "type": "object", + "required": ["warpId"], + "additionalProperties": true, + "properties": { + "warpId": { + "$ref": "#/$defs/nonEmptyString", + "description": "Warp-managed MCP server ID. This is the only key an entry may carry." + } + } + }, + "description": "Managed MCP servers keyed by the name the agent sees. Declaring the field at Agent or Automation level replaces the inherited map." + }, + "harnessAuth": { + "type": ["object", "null"], + "description": "Credentials for a non-Oz harness. Null explicitly clears inherited auth.", + "additionalProperties": true, + "properties": { + "source": { + "$ref": "#/$defs/nonEmptyString", + "x-warp-known-values": ["managedSecret", "workerEnvironment"] + }, + "secretName": { "$ref": "#/$defs/nonEmptyString" } + }, + "if": { "type": "object" }, + "then": { + "required": ["source"], + "allOf": [ + { + "if": { + "properties": { "source": { "const": "managedSecret" } }, + "required": ["source"] + }, + "then": { "required": ["secretName"] } + }, + { + "if": { + "properties": { "source": { "const": "workerEnvironment" } }, + "required": ["source"] + }, + "then": { "not": { "required": ["secretName"] } } + } + ] + } + }, + "harnessCommonProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "$ref": "#/$defs/nullableNonEmptyString", + "x-warp-known-values": ["oz", "claude", "claude-code", "codex", "gemini"], + "description": "Harness config name. claude-code is an accepted alias for claude." + }, + "model": { "$ref": "#/$defs/nullableNonEmptyString" }, + "reasoningLevel": { + "$ref": "#/$defs/clearableString", + "description": "The current server rejects this on the oz harness. Per-harness capabilities change, so the bundled schema leaves that judgement to the server." + }, + "auth": { "$ref": "#/$defs/harnessAuth" } + } + }, + "harnessOverride": { + "allOf": [ + { "$ref": "#/$defs/harnessCommonProperties" }, + { + "anyOf": [ + { + "required": ["type"], + "properties": { "type": { "type": "string" } } + }, + { + "required": ["model"], + "properties": { "model": { "type": "string" } } + }, + { "required": ["reasoningLevel"] }, + { "required": ["auth"] } + ] + } + ], + "description": "Sparse harness override. Must declare at least one of type, model, reasoningLevel, or auth." + }, + "harnessDefault": { + "allOf": [ + { "$ref": "#/$defs/harnessCommonProperties" }, + { + "required": ["type", "model"], + "properties": { + "type": { + "$ref": "#/$defs/nonEmptyString" + }, + "model": { "$ref": "#/$defs/nonEmptyString" } + } + } + ], + "description": "Factory-level harness default. Both type and model are required." + }, + "modelXorHarnessRequired": { + "oneOf": [ + { "required": ["model"] }, + { "required": ["harness"] } + ], + "description": "Declare exactly one of model (Oz harness shorthand) or harness (explicit harness block)." + }, + "modelXorHarnessOptional": { + "not": { "required": ["model", "harness"] }, + "description": "model and harness are mutually exclusive." + }, + "ozModelDefault": { + "$ref": "#/$defs/nonEmptyString", + "description": "Oz model ID. Shorthand for harness: {type: oz, model: }. Mutually exclusive with harness." + }, + "ozModelOverride": { + "$ref": "#/$defs/nullableNonEmptyString", + "description": "Oz model ID, or null to inherit. Shorthand for harness: {type: oz, model: }. Mutually exclusive with harness." + }, + "runnerRef": { + "$ref": "#/$defs/nullableNonEmptyString", + "description": "Name of a runner declared under runners/.yaml, or null to inherit." + }, + "environmentId": { + "$ref": "#/$defs/nullableNonEmptyString", + "description": "Cloud environment ID the run executes in, or null to inherit." + }, + "workerHost": { + "$ref": "#/$defs/clearableString", + "description": "Self-hosted worker host. Null or empty clears an inherited host and defers to the workspace default." + } + } +} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json new file mode 100644 index 0000000..c004059 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "factory.schema.json", + "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", + "title": "factory.yaml (v1alpha1)", + "description": "The Factory root document. Exactly one factory.yaml must exist at the registered Factory root.", + "type": "object", + "required": ["schemaVersion", "name", "repositories", "agentDefaults"], + "additionalProperties": true, + "properties": { + "schemaVersion": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["v1alpha1"], + "description": "Tree schema version. These schemas describe v1alpha1. A tree declaring a newer version is reported as unvalidatable rather than validated against v1alpha1 rules." + }, + "name": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "description": "Factory name." + }, + "description": { + "type": ["string", "null"] + }, + "alias": { "$ref": "common.schema.json#/$defs/alias" }, + "credentialStrategy": { "$ref": "common.schema.json#/$defs/credentialStrategy" }, + "repositories": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "description": "GitHub repositories in the Factory's scope.", + "items": { + "type": "object", + "required": ["owner", "name"], + "additionalProperties": true, + "properties": { + "owner": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, + "name": { "$ref": "common.schema.json#/$defs/nonEmptyString" } + } + } + }, + "secrets": { "$ref": "common.schema.json#/$defs/uniqueSecrets" }, + "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, + "cloudProviders": { + "$ref": "#/$defs/cloudProviders", + "description": "Current cloud-provider identity federation section." + }, + "providers": { + "$ref": "#/$defs/cloudProviders", + "description": "Legacy read-only alias for cloudProviders. Author cloudProviders in new files." + }, + "integrations": { + "type": "array", + "uniqueItems": true, + "description": "Integration providers attached to the Factory. An empty list explicitly detaches every provider. github is not declarable here: repository access comes from repositories.", + "items": { + "type": "object", + "required": ["type"], + "additionalProperties": true, + "properties": { + "type": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["jira", "linear", "slack"], + "description": "Provider slug. The server lowercases and trims the value; author the canonical known spelling." + } + } + } + }, + "agentDefaults": { + "type": "object", + "additionalProperties": true, + "description": "Execution defaults every Agent inherits. Exactly one of model or harness is required.", + "properties": { + "model": { "$ref": "common.schema.json#/$defs/ozModelDefault" }, + "harness": { "$ref": "common.schema.json#/$defs/harnessDefault" }, + "runner": { "$ref": "common.schema.json#/$defs/runnerRef" }, + "environmentId": { "$ref": "common.schema.json#/$defs/environmentId" }, + "secrets": { "$ref": "common.schema.json#/$defs/secrets" }, + "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, + "workerHost": { "$ref": "common.schema.json#/$defs/workerHost" } + }, + "allOf": [{ "$ref": "common.schema.json#/$defs/modelXorHarnessRequired" }] + } + }, + "$defs": { + "cloudProviders": { + "type": "object", + "additionalProperties": true, + "description": "Cloud provider identity federation used by agent runs.", + "properties": { + "gcp": { + "type": "object", + "required": [ + "projectNumber", + "workloadIdentityFederationPoolId", + "workloadIdentityFederationProviderId" + ], + "additionalProperties": true, + "properties": { + "projectNumber": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "description": "Quote the value so YAML keeps it a string rather than an integer." + }, + "workloadIdentityFederationPoolId": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, + "workloadIdentityFederationProviderId": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, + "serviceAccountEmail": { "$ref": "common.schema.json#/$defs/nonEmptyString" } + } + }, + "aws": { + "type": "object", + "required": ["roleArn"], + "additionalProperties": true, + "properties": { + "roleArn": { "$ref": "common.schema.json#/$defs/nonEmptyString" } + } + } + } + } + } +} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json new file mode 100644 index 0000000..f64e383 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json @@ -0,0 +1,135 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "runner.schema.json", + "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", + "title": "runners/.yaml (v1alpha1)", + "description": "A Runner document. The Runner's name comes from the file name, never from a field. Platform and instance-shape rules are enforced when the plan is applied, not by the file parser.", + "type": "object", + "additionalProperties": true, + "properties": { + "description": { + "type": ["string", "null"] + }, + "setupCommands": { + "type": "array", + "items": { "type": "string" }, + "description": "Shell commands run while preparing the sandbox." + }, + "instanceShape": { + "type": "object", + "required": ["vcpus", "memoryGb"], + "additionalProperties": true, + "properties": { + "vcpus": { "type": "integer", "minimum": 1 }, + "memoryGb": { "type": "integer", "minimum": 1 } + } + }, + "platform": { + "type": "object", + "additionalProperties": true, + "properties": { + "os": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["linux", "macos"], + "description": "Defaults to linux when omitted." + }, + "arch": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["x86_64", "aarch64"], + "description": "Defaults to x86_64 on linux and aarch64 on macos. Supported pairs: linux/x86_64, linux/aarch64, macos/aarch64." + }, + "linux": { + "type": "object", + "additionalProperties": true, + "properties": { + "dockerImage": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "description": "Container image the Linux sandbox boots. Required for every Linux runner." + } + } + }, + "mac": { + "type": "object", + "required": ["version"], + "additionalProperties": true, + "properties": { + "version": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["14", "15", "26", "27"], + "description": "Quote the value so YAML keeps it a string. Defaults to 26 when the mac section is omitted." + } + } + } + } + } + }, + "allOf": [ + { + "if": { "$ref": "#/$defs/declaresMacOS" }, + "then": { + "properties": { + "platform": { + "not": { "required": ["linux"] } + } + } + } + }, + { + "if": { "$ref": "#/$defs/declaresLinuxOrDefault" }, + "then": { + "required": ["platform"], + "properties": { + "platform": { + "required": ["linux"], + "not": { "required": ["mac"] }, + "properties": { + "linux": { "required": ["dockerImage"] } + } + }, + "instanceShape": { "$ref": "#/$defs/linuxInstanceShape" } + } + } + } + ], + "$defs": { + "declaresMacOS": { + "type": "object", + "required": ["platform"], + "properties": { + "platform": { + "type": "object", + "required": ["os"], + "properties": { "os": { "const": "macos" } } + } + } + }, + "declaresLinuxOrDefault": { + "anyOf": [ + { "not": { "required": ["platform"] } }, + { + "required": ["platform"], + "properties": { + "platform": { "not": { "required": ["os"] } } + } + }, + { + "required": ["platform"], + "properties": { + "platform": { + "required": ["os"], + "properties": { "os": { "const": "linux" } } + } + } + } + ] + }, + "linuxInstanceShape": { + "required": ["vcpus", "memoryGb"], + "properties": { + "vcpus": { "type": "integer", "minimum": 1 }, + "memoryGb": { "type": "integer", "minimum": 1 } + }, + "description": "Both values must be powers of two; the bundled validator checks that constraint because JSON Schema cannot express it." + } + } +} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json new file mode 100644 index 0000000..4500f8d --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "scorer.schema.json", + "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", + "title": "scorers//scorer.md frontmatter (v1alpha1)", + "description": "YAML frontmatter of a Scorer file. The Scorer name comes from its directory, and the Markdown body is its required rubric.", + "type": "object", + "required": ["agents", "labels", "passingScore", "model"], + "additionalProperties": true, + "properties": { + "description": { + "type": ["string", "null"] + }, + "agents": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "common.schema.json#/$defs/nonEmptyString" + }, + "description": "Names of Agents declared by this Factory." + }, + "enabled": { + "type": "boolean", + "description": "Defaults to true." + }, + "output": { + "$ref": "common.schema.json#/$defs/nonEmptyString", + "x-warp-known-values": ["classification"], + "description": "Scorer output form. classification is the current v1 form; newer forms are preserved for forward compatibility." + }, + "labels": { + "type": "array", + "minItems": 1, + "x-warp-known-max-items": 20, + "description": "Classifications this Scorer may return. The current server accepts at most 20; a larger set is left for the server to judge.", + "items": { + "type": "object", + "required": ["value", "score"], + "additionalProperties": true, + "properties": { + "value": { + "$ref": "common.schema.json#/$defs/nonEmptyString" + }, + "description": { + "type": ["string", "null"] + }, + "score": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + }, + "passingScore": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "samplingRate": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage of eligible runs to score. Defaults to 25. Zero is invalid; use enabled: false." + }, + "model": { + "$ref": "common.schema.json#/$defs/nonEmptyString" + }, + "selfImprovement": { + "type": "boolean", + "description": "Defaults to false." + } + } +} diff --git a/plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py b/plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py new file mode 100755 index 0000000..63739c5 --- /dev/null +++ b/plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py @@ -0,0 +1,1624 @@ +#!/usr/bin/env python3 +"""Validate a Factory file tree, against warp-server when it is reachable. + +Usage: + python3 validate_factory_files.py [FACTORY_ROOT] [--json] [--offline] + [--server-root URL] [--schemas DIR] + +FACTORY_ROOT defaults to the current directory and must contain factory.yaml. + +The server owns the Factory file format, so this script asks it first: it reads +the tree's declared schemaVersion, confirms the server publishes that version, +and submits the tree to the validation endpoint. Only when that path is +unavailable does it fall back to the schemas and checks bundled here, and it +always says which of the two ran. A bundled result can be older than the +server; presenting one as though the server had agreed would be a lie. + +The script is intentionally dependency-free: it ships a restricted YAML reader +for the canonical forms this skill emits (no anchors, aliases, explicit tags, +or multiple documents) and a JSON Schema evaluator covering the keywords the +bundled schemas use. It is not a general-purpose YAML implementation. Anything +it cannot read confidently is reported rather than guessed at. + +Neither path resolves server state. Model IDs, environment IDs, secret names, +runner references, MCP server IDs, integration availability, and the values of +provider name aliases are all checked when the plan is applied. + +FORWARD COMPATIBILITY - DO NOT TIGHTEN +-------------------------------------- +The bundled schemas ship inside a Warp release, so they are routinely older +than the warp-server they are used against. They therefore accept some input +the current server rejects, on purpose. Unknown properties, agent types, +credential strategies, harness types and per-harness capabilities, integration +slugs, trigger providers and events, runner platforms, Scorer output forms, and +the Scorer label cap are all deferred to the server. + +This tolerance is for the offline floor only. The server's own schemas are +exact for the version they describe, because they cannot lag the parser that +serves them. Do not copy them over these. + +If you are here because the offline path accepted something the server rejects, +the fix is usually a clearer server diagnostic, not a stricter schema. A false +rejection is far more expensive than a false acceptance: it blocks correct +work and invites an agent to "repair" valid configuration by deleting it, +whereas the server revalidates every tree at apply time anyway. + +Two checks are deliberately kept strict, and both are scoped so drift cannot +trip them: trigger filter keys apply only when the provider and event are both +recognized, and an unrecognized schemaVersion stops validation instead of +misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import unicodedata +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Optional + +SCHEMA_BY_KIND = { + "factory": "factory.schema.json", + "agent": "agent.schema.json", + "automation": "automation.schema.json", + "runner": "runner.schema.json", + "scorer": "scorer.schema.json", +} + +MAIN_AGENT_TYPES = {"MAIN", "FOREMAN"} + + +class Problem: + """One validation failure, located as precisely as the input allows.""" + + def __init__(self, path: str, message: str, line: Optional[int] = None, pointer: str = ""): + self.path = path + self.message = message + self.line = line + self.pointer = pointer + + def as_dict(self) -> dict[str, Any]: + return { + "path": self.path, + "line": self.line, + "pointer": self.pointer, + "message": self.message, + } + + def render(self) -> str: + location = self.path + if self.line is not None: + location += f":{self.line}" + if self.pointer: + return f"{location}: {self.pointer}: {self.message}" + return f"{location}: {self.message}" + + +# --------------------------------------------------------------------------- +# Restricted YAML reader +# --------------------------------------------------------------------------- + + +class YamlError(Exception): + def __init__(self, message: str, line: int): + super().__init__(message) + self.message = message + self.line = line + + +class _Line: + __slots__ = ("number", "indent", "content") + + def __init__(self, number: int, indent: int, content: str): + self.number = number + self.indent = indent + self.content = content + + +# Characters after which a quote opens a quoted scalar. Anywhere else a quote +# is an ordinary character, so "It's a thing" stays a plain scalar rather than +# an unterminated string. +_VALUE_START_CHARS = ":,[{-" + + +def _strip_comment(raw: str, line_number: int) -> str: + """Remove a trailing comment, honoring quoted scalars.""" + out: list[str] = [] + quote: Optional[str] = None + previous = "" + index = 0 + while index < len(raw): + char = raw[index] + if quote: + out.append(char) + if char == "\\" and quote == '"' and index + 1 < len(raw): + out.append(raw[index + 1]) + index += 2 + continue + if char == quote: + if quote == "'" and index + 1 < len(raw) and raw[index + 1] == "'": + out.append("'") + index += 2 + continue + quote = None + index += 1 + continue + if char in "\"'" and (previous == "" or previous in _VALUE_START_CHARS): + quote = char + out.append(char) + previous = char + index += 1 + continue + if char == "#" and (index == 0 or raw[index - 1] in " \t"): + break + out.append(char) + if char not in " \t": + previous = char + index += 1 + if quote: + raise YamlError("unterminated quoted string", line_number) + return "".join(out).rstrip() + + +def _reject_unsupported(content: str, line_number: int) -> None: + """Reject line-level constructs the Factory file parser does not accept. + + Anchors, aliases, and tags are checked in [_parse_scalar] instead, because + they are only meaningful where a node begins; scanning the whole line + rejects ordinary prose such as "A & B" or "see *this*". + """ + if content.strip() in ("---", "..."): + raise YamlError("multiple YAML documents are not permitted", line_number) + if re.match(r"^\s*<<\s*:", content): + raise YamlError("yaml merge keys are not permitted", line_number) + + +def _opens_block_scalar(content: str, line_number: int) -> bool: + while content.startswith("- "): + content = content[2:].lstrip() + entry = _split_key(content, line_number) + value = entry[1] if entry is not None else content + return value[:1] in ("|", ">") + + +def _skip_block_scalar_body(raw_lines: list[str], index: int, header_indent: int) -> int: + """Return the index of the first line after a block scalar's body.""" + while index < len(raw_lines): + raw = raw_lines[index] + if raw.strip() == "": + index += 1 + continue + if len(raw) - len(raw.lstrip(" ")) <= header_indent: + break + index += 1 + return index + + +def _read_lines(text: str) -> list[_Line]: + raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + lines: list[_Line] = [] + index = 0 + while index < len(raw_lines): + raw = raw_lines[index] + number = index + 1 + index += 1 + if "\t" in raw[: len(raw) - len(raw.lstrip(" \t"))]: + raise YamlError("tabs are not permitted for indentation", number) + content = _strip_comment(raw, number) + if not content.strip(): + continue + _reject_unsupported(content, number) + indent = len(content) - len(content.lstrip(" ")) + stripped = content.strip() + lines.append(_Line(number, indent, stripped)) + # A block scalar's body is opaque text. Leaving it out of the + # structural line list keeps its content from being read as YAML. + if _opens_block_scalar(stripped, number): + index = _skip_block_scalar_body(raw_lines, index, indent) + return lines + + +_INT_RE = re.compile(r"^[-+]?[0-9]+$") +_HEX_RE = re.compile(r"^[-+]?0x[0-9a-fA-F]+$") +_OCT_RE = re.compile(r"^[-+]?0o[0-7]+$") +_FLOAT_RE = re.compile(r"^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$") +_YAML_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[ \t]*(?:Z|[-+]\d{1,2}(?::\d{2})?))?)?$") + + +def _parse_scalar(token: str, line_number: int) -> Any: + token = token.strip() + if token == "" or token == "~" or token in ("null", "Null", "NULL"): + return None + if token.startswith("'"): + if not token.endswith("'") or len(token) < 2: + raise YamlError("invalid single-quoted string", line_number) + return token[1:-1].replace("''", "'") + if token.startswith('"'): + try: + return json.loads(token) + except json.JSONDecodeError as error: + raise YamlError(f"invalid double-quoted string: {error.msg}", line_number) from error + if token[0] in "&*": + raise YamlError("yaml anchors and aliases are not permitted", line_number) + if token[0] == "!": + raise YamlError("explicit yaml tags are not permitted", line_number) + if token in ("true", "True", "TRUE"): + return True + if token in ("false", "False", "FALSE"): + return False + if _INT_RE.match(token): + return int(token, 10) + if _HEX_RE.match(token): + return int(token, 16) + if _OCT_RE.match(token): + return int(token, 8) + if _FLOAT_RE.match(token): + return float(token) + if _YAML_DATE_RE.match(token): + raise YamlError("timestamps must be quoted so YAML keeps them as strings", line_number) + if token.lower() in {".inf", "+.inf", "-.inf", ".nan"}: + raise YamlError("non-finite YAML numbers are not permitted", line_number) + return token + + +def _split_key(content: str, line_number: int) -> Optional[tuple[str, str]]: + """Split `key: value`, honoring quoted keys. Returns None when absent.""" + quote: Optional[str] = None + index = 0 + while index < len(content): + char = content[index] + if quote: + if char == "\\" and quote == '"' and index + 1 < len(content): + index += 2 + continue + if char == quote: + if quote == "'" and index + 1 < len(content) and content[index + 1] == "'": + index += 2 + continue + quote = None + index += 1 + continue + if char in "\"'": + quote = char + index += 1 + continue + if char in "[{": + return None + if char == ":" and (index + 1 == len(content) or content[index + 1] in " \t"): + key_token = content[:index].strip() + key = _parse_scalar(key_token, line_number) + if not isinstance(key, str): + key = key_token + return key, content[index + 1 :].strip() + index += 1 + return None + + +def _parse_flow(text: str, line_number: int) -> Any: + value, rest = _parse_flow_value(text.strip(), line_number) + if rest.strip(): + raise YamlError("unexpected trailing content after flow collection", line_number) + return value + + +def _parse_flow_value(text: str, line_number: int) -> tuple[Any, str]: + text = text.lstrip() + if not text: + raise YamlError("unexpected end of flow collection", line_number) + if text[0] == "[": + items: list[Any] = [] + rest = text[1:].lstrip() + if rest.startswith("]"): + return items, rest[1:] + while True: + item, rest = _parse_flow_value(rest, line_number) + items.append(item) + rest = rest.lstrip() + if rest.startswith(","): + rest = rest[1:].lstrip() + if rest.startswith("]"): + return items, rest[1:] + continue + if rest.startswith("]"): + return items, rest[1:] + raise YamlError("unterminated flow sequence", line_number) + if text[0] == "{": + mapping: dict[str, Any] = {} + rest = text[1:].lstrip() + if rest.startswith("}"): + return mapping, rest[1:] + while True: + key_text, rest = _read_flow_scalar(rest, line_number) + rest = rest.lstrip() + if not rest.startswith(":"): + raise YamlError("flow mapping entry is missing ':'", line_number) + value, rest = _parse_flow_value(rest[1:], line_number) + key = _parse_scalar(key_text, line_number) + if not isinstance(key, str): + key = key_text.strip() + if key in mapping: + raise YamlError(f'duplicate key "{key}"', line_number) + mapping[key] = value + rest = rest.lstrip() + if rest.startswith(","): + rest = rest[1:].lstrip() + if rest.startswith("}"): + return mapping, rest[1:] + continue + if rest.startswith("}"): + return mapping, rest[1:] + raise YamlError("unterminated flow mapping", line_number) + token, rest = _read_flow_scalar(text, line_number) + return _parse_scalar(token, line_number), rest + + +def _read_flow_scalar(text: str, line_number: int) -> tuple[str, str]: + text = text.lstrip() + if text[:1] in ("'", '"'): + quote = text[0] + index = 1 + while index < len(text): + if text[index] == "\\" and quote == '"': + index += 2 + continue + if text[index] == quote: + if quote == "'" and text[index + 1 : index + 2] == "'": + index += 2 + continue + return text[: index + 1], text[index + 1 :] + index += 1 + raise YamlError("unterminated quoted string in flow collection", line_number) + index = 0 + while index < len(text) and text[index] not in ",]}:": + index += 1 + return text[:index].strip(), text[index:] + + +class _Reader: + def __init__(self, lines: list[_Line], raw_lines: list[str]): + self.lines = lines + self.raw_lines = raw_lines + self.index = 0 + + def peek(self) -> Optional[_Line]: + return self.lines[self.index] if self.index < len(self.lines) else None + + def parse_block(self, indent: int) -> Any: + line = self.peek() + if line is None or line.indent < indent: + return None + if line.content.startswith("- ") or line.content == "-": + return self._parse_sequence(line.indent) + return self._parse_mapping(line.indent) + + def _parse_sequence(self, indent: int) -> list[Any]: + items: list[Any] = [] + while True: + line = self.peek() + if line is None or line.indent != indent: + break + if not (line.content.startswith("- ") or line.content == "-"): + break + self.index += 1 + remainder = line.content[1:].strip() + if remainder == "": + items.append(self.parse_block(indent + 1)) + continue + entry = _split_key(remainder, line.number) + if entry is not None: + # An inline mapping entry opens a mapping whose remaining keys + # are indented to the column the first key started at. + after_dash = line.content[1:] + lead = len(after_dash) - len(after_dash.lstrip(" ")) + inline_indent = indent + 1 + lead + items.append(self._parse_inline_mapping(entry, line.number, inline_indent)) + continue + items.append(self._parse_value(remainder, line.number, indent)) + return items + + def _parse_inline_mapping( + self, entry: tuple[str, str], line_number: int, indent: int + ) -> dict[str, Any]: + key, value_token = entry + mapping: dict[str, Any] = {key: self._parse_value(value_token, line_number, indent)} + return self._parse_mapping(indent, existing=mapping) + + def _parse_mapping( + self, indent: int, existing: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + mapping: dict[str, Any] = existing if existing is not None else {} + while True: + line = self.peek() + if line is None or line.indent != indent: + break + if line.content.startswith("- "): + break + entry = _split_key(line.content, line.number) + if entry is None: + raise YamlError(f"expected 'key: value', found {line.content!r}", line.number) + self.index += 1 + key, value_token = entry + if key in mapping: + raise YamlError(f'duplicate key "{key}"', line.number) + mapping[key] = self._parse_value(value_token, line.number, indent) + return mapping + + def _parse_value(self, token: str, line_number: int, indent: int) -> Any: + if token.startswith("|") or token.startswith(">"): + return self._parse_block_scalar(token, line_number, indent) + if token.startswith("[") or token.startswith("{"): + return _parse_flow(token, line_number) + if token != "": + return _parse_scalar(token, line_number) + nested = self.peek() + if nested is None or nested.indent <= indent: + return None + return self.parse_block(nested.indent) + + def _parse_block_scalar(self, header: str, line_number: int, indent: int) -> str: + style = header[0] + chomp = "clip" + if "-" in header[1:]: + chomp = "strip" + elif "+" in header[1:]: + chomp = "keep" + collected: list[str] = [] + # line_number is 1-based, so it indexes the line after the header. + cursor = line_number + block_indent: Optional[int] = None + while cursor < len(self.raw_lines): + raw = self.raw_lines[cursor] + if raw.strip() == "": + collected.append("") + cursor += 1 + continue + current_indent = len(raw) - len(raw.lstrip(" ")) + if current_indent <= indent: + break + if block_indent is None: + block_indent = current_indent + collected.append(raw[block_indent:]) + cursor += 1 + if chomp != "keep": + while collected and collected[-1] == "": + collected.pop() + if style == "|": + text = "\n".join(collected) + else: + text = " ".join(part.strip() for part in collected if part.strip()) + if chomp == "strip" or not text: + return text + return text + "\n" + + +def load_yaml(text: str) -> Any: + """Parse the restricted YAML subset the Factory file format accepts.""" + raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + lines = _read_lines(text) + if not lines: + return None + reader = _Reader(lines, raw_lines) + value = reader.parse_block(lines[0].indent) + remaining = reader.peek() + if remaining is not None: + raise YamlError(f"unexpected content {remaining.content!r}", remaining.number) + return value + + +def split_frontmatter(text: str) -> tuple[str, str, int]: + """Return a Markdown resource's frontmatter, body, and line offset.""" + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + lines = normalized.split("\n") + if not lines or lines[0].rstrip() != "---": + raise YamlError("resource file must start with a frontmatter fence (---)", 1) + for index in range(1, len(lines)): + if lines[index].rstrip() == "---": + return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :]), 1 + raise YamlError("frontmatter is missing a closing fence (---)", 1) + + +# --------------------------------------------------------------------------- +# JSON Schema evaluator +# --------------------------------------------------------------------------- + +_TYPE_CHECKS = { + "object": lambda value: isinstance(value, dict), + "array": lambda value: isinstance(value, list), + "string": lambda value: isinstance(value, str), + "integer": lambda value: isinstance(value, int) and not isinstance(value, bool), + "number": lambda value: isinstance(value, (int, float)) and not isinstance(value, bool), + "boolean": lambda value: isinstance(value, bool), + "null": lambda value: value is None, +} + + +class SchemaStore: + """Loads sibling schema files and resolves local and relative $refs.""" + + def __init__(self, directory: Path): + self.directory = directory + self._cache: dict[str, Any] = {} + + def document(self, filename: str) -> Any: + if filename not in self._cache: + path = self.directory / filename + self._cache[filename] = json.loads(path.read_text(encoding="utf-8")) + return self._cache[filename] + + def resolve(self, ref: str, current: str) -> tuple[Any, str]: + filename, _, pointer = ref.partition("#") + target = filename or current + document = self.document(target) + node = document + for token in [segment for segment in pointer.split("/") if segment]: + token = token.replace("~1", "/").replace("~0", "~") + node = node[token] + return node, target + + +# Keywords the evaluator implements, and the annotations it may ignore. A +# keyword in neither set is reported rather than skipped: silently ignoring an +# unimplemented keyword would under-validate without any signal. +_SUPPORTED_KEYWORDS = frozenset( + { + "$ref", + "type", + "const", + "enum", + "minLength", + "maxLength", + "pattern", + "minimum", + "maximum", + "minItems", + "maxItems", + "uniqueItems", + "items", + "required", + "minProperties", + "properties", + "additionalProperties", + "propertyNames", + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", + } +) +_ANNOTATION_KEYWORDS = frozenset( + { + "$schema", + "$id", + "$defs", + "$comment", + "title", + "description", + "x-warp-character-class", + "x-warp-known-max-items", + "x-warp-known-values", + "x-warp-max-trimmed-runes", + } +) + + + +def _matches_pattern(pattern: str, value: str) -> bool: + return re.search(pattern, value) is not None + + +def _describe(value: Any) -> str: + for name, check in _TYPE_CHECKS.items(): + if name != "number" and check(value): + return name + return type(value).__name__ + + +def validate_instance( + instance: Any, + schema: Any, + store: SchemaStore, + document: str, + pointer: str = "", +) -> list[str]: + """Evaluate the JSON Schema keywords used by the bundled schemas.""" + if schema is True or schema == {}: + return [] + if schema is False: + return [f"{pointer or '/'}: no value is allowed here"] + + errors: list[str] = [] + + unsupported = set(schema) - _SUPPORTED_KEYWORDS - _ANNOTATION_KEYWORDS + if unsupported: + listed = ", ".join(sorted(unsupported)) + errors.append( + f"{pointer or '/'}: this validator does not implement schema keyword(s) {listed}; " + "its result is incomplete until they are added" + ) + + if "$ref" in schema: + target, target_document = store.resolve(schema["$ref"], document) + errors.extend(validate_instance(instance, target, store, target_document, pointer)) + + if "type" in schema: + declared = schema["type"] + names = declared if isinstance(declared, list) else [declared] + if not any(_TYPE_CHECKS[name](instance) for name in names): + errors.append( + f"{pointer or '/'}: expected {' or '.join(names)}, found {_describe(instance)}" + ) + return errors + + if "const" in schema and instance != schema["const"]: + errors.append(f"{pointer or '/'}: must be {json.dumps(schema['const'])}") + if "enum" in schema and instance not in schema["enum"]: + allowed = ", ".join(json.dumps(option) for option in schema["enum"]) + errors.append(f"{pointer or '/'}: {json.dumps(instance)} must be one of {allowed}") + + if isinstance(instance, str): + if "minLength" in schema and len(instance) < schema["minLength"]: + required = schema["minLength"] + detail = "must not be empty" if required == 1 else f"must be at least {required} characters" + errors.append(f"{pointer or '/'}: {detail}") + if "maxLength" in schema and len(instance) > schema["maxLength"]: + errors.append(f"{pointer or '/'}: must be at most {schema['maxLength']} characters") + if "pattern" in schema and not _matches_pattern(schema["pattern"], instance): + errors.append(f"{pointer or '/'}: {json.dumps(instance)} does not match the required format") + + if isinstance(instance, (int, float)) and not isinstance(instance, bool): + if "minimum" in schema and instance < schema["minimum"]: + errors.append(f"{pointer or '/'}: must be at least {schema['minimum']}") + if "maximum" in schema and instance > schema["maximum"]: + errors.append(f"{pointer or '/'}: must be at most {schema['maximum']}") + + if isinstance(instance, list): + if "minItems" in schema and len(instance) < schema["minItems"]: + errors.append(f"{pointer or '/'}: must contain at least {schema['minItems']} entries") + if "maxItems" in schema and len(instance) > schema["maxItems"]: + errors.append(f"{pointer or '/'}: must contain at most {schema['maxItems']} entries") + if schema.get("uniqueItems") and _has_duplicates(instance): + errors.append(f"{pointer or '/'}: entries must be unique") + if "items" in schema: + for index, item in enumerate(instance): + errors.extend( + validate_instance(item, schema["items"], store, document, f"{pointer}/{index}") + ) + + if isinstance(instance, dict): + for name in schema.get("required", []): + if name not in instance: + errors.append(f"{pointer or '/'}: {name} is required") + if "minProperties" in schema and len(instance) < schema["minProperties"]: + errors.append(f"{pointer or '/'}: must declare at least {schema['minProperties']} field") + properties = schema.get("properties", {}) + for name, value in instance.items(): + if name in properties: + errors.extend( + validate_instance(value, properties[name], store, document, f"{pointer}/{name}") + ) + elif "additionalProperties" in schema: + additional = schema["additionalProperties"] + if additional is False: + known = ", ".join(sorted(properties)) or "none" + errors.append( + f"{pointer or '/'}: unknown field {json.dumps(name)} (accepted: {known})" + ) + else: + errors.extend( + validate_instance(value, additional, store, document, f"{pointer}/{name}") + ) + if "propertyNames" in schema: + for name in instance: + errors.extend( + validate_instance( + name, schema["propertyNames"], store, document, f"{pointer}/{name}" + ) + ) + + for subschema in schema.get("allOf", []): + errors.extend(validate_instance(instance, subschema, store, document, pointer)) + + if "anyOf" in schema: + branches = [ + validate_instance(instance, subschema, store, document, pointer) + for subschema in schema["anyOf"] + ] + if all(branch for branch in branches): + errors.append(_combine(pointer, schema, branches, "does not match any accepted form")) + + if "oneOf" in schema: + branches = [ + validate_instance(instance, subschema, store, document, pointer) + for subschema in schema["oneOf"] + ] + matched = [index for index, branch in enumerate(branches) if not branch] + if len(matched) == 0: + errors.append(_combine(pointer, schema, branches, "does not match any accepted form")) + elif len(matched) > 1: + errors.append( + f"{pointer or '/'}: matches more than one mutually exclusive form" + + (f" ({schema['description']})" if "description" in schema else "") + ) + + if "not" in schema and not validate_instance(instance, schema["not"], store, document, pointer): + errors.append( + f"{pointer or '/'}: " + + (schema.get("description") or "this form is not allowed here") + ) + + if "if" in schema: + matched = not validate_instance(instance, schema["if"], store, document, pointer) + branch = schema.get("then") if matched else schema.get("else") + if branch is not None: + errors.extend(validate_instance(instance, branch, store, document, pointer)) + + return errors + + +def _combine(pointer: str, schema: Any, branches: list[list[str]], summary: str) -> str: + detail = schema.get("description") + head = f"{pointer or '/'}: {detail or summary}" + nested = sorted({message for branch in branches for message in branch}) + if not nested: + return head + return head + " — " + "; ".join(nested[:4]) + + +def _has_duplicates(items: list[Any]) -> bool: + seen: list[str] = [] + for item in items: + key = json.dumps(item, sort_keys=True) + if key in seen: + return True + seen.append(key) + return False + + +# --------------------------------------------------------------------------- +# Factory tree traversal +# --------------------------------------------------------------------------- + + +def classify(relative: str) -> tuple[str, str]: + """Mirror the server's path classification. Returns (kind, name).""" + if relative == "factory.yaml": + return "factory", "" + segments = relative.split("/") + if len(segments) >= 2 and segments[0] == "skills": + return "skill", "" + if len(segments) >= 4 and segments[0] == "agents" and segments[2] == "skills": + return "skill", "" + if len(segments) == 3 and segments[0] == "agents" and segments[2] == "agent.md": + return ("agent", segments[1]) if _valid_name(segments[1]) else ("invalid", "") + if len(segments) == 3 and segments[0] == "automations" and segments[2] == "automation.md": + return ("automation", segments[1]) if _valid_name(segments[1]) else ("invalid", "") + if len(segments) == 2 and segments[0] == "automations" and segments[1].endswith(".md"): + name = segments[1][: -len(".md")] + return ("automation", name) if _valid_name(name) else ("invalid", "") + if len(segments) == 2 and segments[0] == "runners" and segments[1].endswith(".yaml"): + name = segments[1][: -len(".yaml")] + return ("runner", name) if _valid_name(name) else ("invalid", "") + if len(segments) == 3 and segments[0] == "scorers" and segments[2] == "scorer.md": + return ("scorer", segments[1]) if _valid_name(segments[1]) else ("invalid", "") + if len(segments) == 2 and segments[0] == "scorers" and segments[1].endswith(".md"): + return "invalid", "" + base = segments[-1] + if segments[0] == "agents" and base == "agent.md": + return "invalid", "" + if segments[0] == "automations" and base == "automation.md": + return "invalid", "" + if segments[0] == "runners" and base.endswith(".yaml"): + return "invalid", "" + if segments[0] == "scorers" and base == "scorer.md": + return "invalid", "" + return "unrelated", "" + + +def _valid_name(name: str) -> bool: + return name not in ("", ".", "..") and "/" not in name + +def _resource_files(root: Path) -> list[Path]: + files = [root / "factory.yaml"] + for directory_name in ("agents", "automations", "runners", "scorers"): + resource_root = root / directory_name + if not resource_root.is_dir(): + continue + for directory, child_directories, filenames in os.walk(resource_root): + relative_directory = Path(directory).relative_to(root) + parts = relative_directory.parts + if directory_name == "agents" and len(parts) == 2: + child_directories[:] = [name for name in child_directories if name != "skills"] + files.extend(Path(directory) / filename for filename in filenames) + return sorted(files) + + +def _leaves_factory_root(path: Path, root: Path) -> bool: + """Report whether reading path would follow a link out of the tree. + + The server never resolves links: it parses an in-memory git tree, where a + symlink is a blob whose content is the target path, so it sees the link + itself. Following one here would both diverge from that and read a file the + Factory does not contain - an untrusted repository could otherwise point a + resource at any readable path and have its content echoed back in a parse + error. + """ + if path.is_symlink(): + return True + try: + path.resolve().relative_to(root.resolve()) + except (OSError, ValueError, RuntimeError): + return True + return False + + +SUPPORTED_SCHEMA_VERSION = "v1alpha1" + +SYMLINK_REFUSED = ( + "resource file is a symlink, or resolves outside the Factory root, and was not " + "read. The server parses the repository tree, so it sees the link itself rather " + "than its target and cannot accept this either. Replace it with a real file." +) + + +def validate_tree(root: Path, store: SchemaStore) -> list[Problem]: + problems: list[Problem] = [] + documents: dict[str, tuple[str, str, Any]] = {} + seen_names: dict[tuple[str, str], str] = {} + + if not (root / "factory.yaml").is_file(): + return [Problem("factory.yaml", "factory.yaml is required at the Factory root")] + + for absolute in _resource_files(root): + relative = absolute.relative_to(root).as_posix() + kind, name = classify(relative) + if kind in ("unrelated", "skill"): + continue + if kind == "invalid": + problems.append( + Problem( + relative, + "resource files must use factory.yaml, agents//agent.md, " + "automations//automation.md, runners/.yaml, " + "or scorers//scorer.md", + ) + ) + continue + if kind in ("automation", "runner", "scorer"): + previous = seen_names.get((kind, name)) + if previous is not None: + problems.append( + Problem(relative, f'{kind} "{name}" is also declared by {previous}') + ) + continue + seen_names[(kind, name)] = relative + + if _leaves_factory_root(absolute, root): + problems.append(Problem(relative, SYMLINK_REFUSED)) + continue + + try: + text = absolute.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + problems.append(Problem(relative, f"could not read UTF-8 resource: {error}")) + continue + offset = 0 + body = "" + try: + if kind in ("agent", "automation", "scorer"): + frontmatter, body, offset = split_frontmatter(text) + parsed = load_yaml(frontmatter) if frontmatter.strip() else {} + else: + parsed = load_yaml(text) + except YamlError as error: + problems.append(Problem(relative, error.message, error.line + offset)) + continue + + if parsed is None: + parsed = {} + if not isinstance(parsed, dict): + problems.append(Problem(relative, "document root must be a YAML mapping")) + continue + + documents[relative] = (kind, name, parsed) + schema = store.document(SCHEMA_BY_KIND[kind]) + for message in validate_instance(parsed, schema, store, SCHEMA_BY_KIND[kind]): + pointer, _, detail = message.partition(": ") + problems.append(Problem(relative, detail, pointer=pointer.lstrip("/").replace("/", "."))) + if kind == "automation": + problems.extend(_automation_semantics(relative, parsed)) + elif kind == "factory": + problems.extend(_factory_semantics(relative, parsed)) + elif kind == "runner": + problems.extend(_runner_semantics(relative, parsed)) + elif kind == "scorer": + problems.extend(_scorer_semantics(relative, parsed, body)) + + problems.extend(_validate_cross_file(documents)) + return problems + + +def _scorer_semantics(relative: str, parsed: dict[str, Any], body: str) -> list[Problem]: + problems: list[Problem] = [] + if not body.strip(): + problems.append(Problem(relative, "the Markdown body is the rubric and must not be empty")) + + agents = parsed.get("agents") + if isinstance(agents, list): + normalized_agents = [value.strip() for value in agents if isinstance(value, str)] + if len(set(normalized_agents)) != len(normalized_agents): + problems.append(Problem(relative, "agent names must be unique after trimming", pointer="agents")) + + labels = parsed.get("labels") + threshold = parsed.get("passingScore") + numeric_scores: list[float] = [] + if isinstance(labels, list): + seen_labels: set[str] = set() + for index, label in enumerate(labels): + if not isinstance(label, dict): + continue + value = label.get("value") + if isinstance(value, str): + normalized_value = value.strip() + if normalized_value in seen_labels: + problems.append( + Problem( + relative, + f'duplicate label "{normalized_value}"', + pointer=f"labels.{index}.value", + ) + ) + seen_labels.add(normalized_value) + score = label.get("score") + if isinstance(score, (int, float)) and not isinstance(score, bool): + numeric_scores.append(float(score)) + if ( + isinstance(threshold, (int, float)) + and not isinstance(threshold, bool) + and numeric_scores + ): + threshold_value = float(threshold) + if not any(score >= threshold_value for score in numeric_scores): + problems.append( + Problem( + relative, + "at least one label score must be at or above passingScore", + pointer="passingScore", + ) + ) + if not any(score < threshold_value for score in numeric_scores): + problems.append( + Problem( + relative, + "at least one label score must be below passingScore", + pointer="passingScore", + ) + ) + + sampling_rate = parsed.get("samplingRate") + if isinstance(sampling_rate, (int, float)) and not isinstance(sampling_rate, bool): + if float(sampling_rate) == 0: + problems.append( + Problem( + relative, + "samplingRate must not be 0; use enabled: false to stop scoring", + pointer="samplingRate", + ) + ) + return problems + + +def _factory_semantics(relative: str, parsed: dict[str, Any]) -> list[Problem]: + problems: list[Problem] = [] + alias = parsed.get("alias") + if isinstance(alias, str): + normalized_alias = alias.strip() + if len(normalized_alias) > 60: + problems.append(Problem(relative, "alias must not exceed 60 characters", pointer="alias")) + if any( + unicodedata.category(character)[:1] not in {"L", "N"} and character not in " _.-" + for character in normalized_alias + ): + problems.append( + Problem( + relative, + "alias may only contain letters, digits, spaces, '-', '_', and '.'", + pointer="alias", + ) + ) + + secrets = parsed.get("secrets") + if isinstance(secrets, list): + normalized = [value.strip() for value in secrets if isinstance(value, str)] + if len(set(normalized)) != len(normalized): + problems.append( + Problem(relative, "secret names must be unique after trimming", pointer="secrets") + ) + + repositories = parsed.get("repositories") + if isinstance(repositories, list): + seen: set[tuple[str, str]] = set() + for index, repository in enumerate(repositories): + if not isinstance(repository, dict): + continue + owner, name = repository.get("owner"), repository.get("name") + if not isinstance(owner, str) or not isinstance(name, str): + continue + key = (owner.strip(), name.strip()) + if key in seen: + problems.append( + Problem( + relative, + f"duplicate repository {key[0]}/{key[1]} after trimming", + pointer=f"repositories.{index}", + ) + ) + seen.add(key) + return problems + + +def _runner_semantics(relative: str, parsed: dict[str, Any]) -> list[Problem]: + shape = parsed.get("instanceShape") + platform = parsed.get("platform") + os_name = platform.get("os", "linux") if isinstance(platform, dict) else "linux" + if os_name != "linux" or not isinstance(shape, dict): + return [] + problems: list[Problem] = [] + for field in ("vcpus", "memoryGb"): + value = shape.get(field) + if isinstance(value, int) and not isinstance(value, bool) and value > 0: + if value & (value - 1): + problems.append( + Problem( + relative, + f"{field} must be a power of two for Linux runners", + pointer=f"instanceShape.{field}", + ) + ) + return problems + + +_CRON_DESCRIPTORS = { + "@yearly", + "@annually", + "@monthly", + "@weekly", + "@daily", + "@midnight", + "@hourly", +} +_DURATION_RE = re.compile( + r"^[+-]?(?:0|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|μs|ms|s|m|h))+)$" +) +_MONTH_NAMES = { + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, +} +_DAY_NAMES = {"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6} + + +def _cron_number(value: str, names: Optional[dict[str, int]]) -> Optional[int]: + if names is not None and value.lower() in names: + return names[value.lower()] + if not re.fullmatch(r"\d+", value): + return None + return int(value) + + +def _valid_cron_field( + field: str, minimum: int, maximum: int, names: Optional[dict[str, int]] = None +) -> bool: + for expression in filter(None, field.split(",")): + parts = expression.split("/") + if len(parts) > 2: + return False + base = parts[0] + if len(parts) == 2 and (not parts[1].isdigit() or int(parts[1]) == 0): + return False + if base in {"*", "?"}: + continue + bounds = base.split("-") + if len(bounds) > 2: + return False + start = _cron_number(bounds[0], names) + end = _cron_number(bounds[-1], names) + if start is None or end is None: + return False + if start < minimum or end > maximum or start > end: + return False + return bool(field) + + +def _valid_cron(expression: str) -> bool: + expression = expression.strip() + if expression in _CRON_DESCRIPTORS: + return True + if expression.startswith("@every "): + return _DURATION_RE.fullmatch(expression[len("@every ") :]) is not None + fields = expression.split() + if len(fields) != 5: + return False + return all( + validator + for validator in ( + _valid_cron_field(fields[0], 0, 59), + _valid_cron_field(fields[1], 0, 23), + _valid_cron_field(fields[2], 1, 31), + _valid_cron_field(fields[3], 1, 12, _MONTH_NAMES), + _valid_cron_field(fields[4], 0, 6, _DAY_NAMES), + ) + ) + + +def _automation_semantics(relative: str, parsed: dict[str, Any]) -> list[Problem]: + """Report filter values listed in both in and not_in. + + Such a filter can never match, so the server rejects it rather than + persisting a silently dead subscription. JSON Schema cannot compare two + sibling arrays, so the check lives here. + """ + problems: list[Problem] = [] + triggers = parsed.get("triggers") + if not isinstance(triggers, list): + return problems + schedule_keys: set[str] = set() + for index, trigger in enumerate(triggers): + if not isinstance(trigger, dict): + continue + schedule = trigger.get("schedule") + if isinstance(schedule, dict): + name = schedule.get("name") + normalized_name = name.strip() if isinstance(name, str) else "" + key = f"name:{normalized_name}" if normalized_name else "unnamed" + if key in schedule_keys: + detail = ( + f'duplicate inline schedule name "{normalized_name}"' + if normalized_name + else "at most one inline schedule may omit name" + ) + problems.append( + Problem(relative, detail, pointer=f"triggers.{index}.schedule") + ) + schedule_keys.add(key) + cron = schedule.get("cron") + if isinstance(cron, str) and not _valid_cron(cron): + problems.append( + Problem( + relative, + f"invalid cron expression {json.dumps(cron)}", + pointer=f"triggers.{index}.schedule.cron", + ) + ) + + declared = trigger.get("filter") + if not isinstance(declared, dict): + continue + provider, event = trigger.get("provider"), trigger.get("event") + for field, matcher in declared.items(): + if not isinstance(matcher, dict): + continue + included = matcher.get("in") + excluded = matcher.get("not_in") + if not isinstance(included, list) or not isinstance(excluded, list): + continue + normalize = _matcher_normalizer(provider, event, field) + excluded_keys = {normalize(value) for value in excluded} + for value in included: + if normalize(value) in excluded_keys: + problems.append( + Problem( + relative, + f"{json.dumps(value)} is present in, or equivalent to a value in, " + "both in and not_in, " + "so this filter can never match", + pointer=f"triggers.{index}.filter.{field}", + ) + ) + return problems + + +def _matcher_normalizer(provider: Any, event: Any, field: str): + lowercase_fields: set[tuple[str, str]] = { + ("github", "assignees"), + ("github", "authors"), + ("github", "mentioned"), + ("github", "reviewers"), + ("github", "reviewer_teams"), + ("github", "review_states"), + ("github", "conclusions"), + ("github", "workflows"), + ("gitlab", "repos"), + ("gitlab", "actions"), + ("gitlab", "mentioned"), + ("linear", "mentioned_user_ids"), + ("linear", "labels"), + } + + def normalize(value: Any) -> Any: + if not isinstance(value, str): + return value + if provider == "github" and event == "push" and field == "branches": + return value[len("refs/heads/") :] if value.startswith("refs/heads/") else value + if provider == "slack" and field == "emojis": + emoji = value.strip().strip(":") + skin_tone = emoji.find("::skin-tone-") + if skin_tone >= 0: + emoji = emoji[:skin_tone] + return emoji.lower() + if field == "keywords" and provider in {"linear", "slack", "jira"}: + return value.strip().lower() + if (provider, field) in lowercase_fields: + return value.lower() + return value + + return normalize + + +def _validate_cross_file(documents: dict[str, tuple[str, str, Any]]) -> list[Problem]: + """Check the tree-level rules that no single-document schema can express. + + Runner references are deliberately not checked: a name the tree does not + declare legitimately resolves to an existing team runner on the server. + """ + problems: list[Problem] = [] + agent_names: set[str] = set() + main_agents: list[str] = [] + + for relative, (kind, name, parsed) in documents.items(): + if kind == "agent": + agent_names.add(name) + if str(parsed.get("agentType", "")) in MAIN_AGENT_TYPES: + main_agents.append(relative) + + if not main_agents: + problems.append( + Problem("factory.yaml", "exactly one Agent must declare agentType MAIN or FOREMAN") + ) + elif len(main_agents) > 1: + for relative in sorted(main_agents): + problems.append( + Problem(relative, "only one Agent may declare agentType MAIN or FOREMAN") + ) + + for relative, (kind, _, parsed) in documents.items(): + if kind == "automation": + agent = parsed.get("agent") + if isinstance(agent, str) and agent not in agent_names: + problems.append( + Problem(relative, f'agent "{agent}" must name a declared Agent', pointer="agent") + ) + elif kind == "scorer": + agents = parsed.get("agents") + if not isinstance(agents, list): + continue + for index, agent in enumerate(agents): + if isinstance(agent, str) and agent.strip() not in agent_names: + problems.append( + Problem( + relative, + f'agent "{agent.strip()}" must name a declared Agent', + pointer=f"agents.{index}", + ) + ) + return problems + + +# --------------------------------------------------------------------------- +# Server-backed validation +# --------------------------------------------------------------------------- + +DEFAULT_SERVER_ROOT = "https://app.warp.dev" +SCHEMA_REGISTRY_PATH = "/api/v1/factory-files/schemas" +VALIDATE_PATH = "/api/v1/factory-files/validate" + +# Bounded so an unreachable or slow server degrades to the offline floor in +# seconds rather than stalling an authoring session. +REQUEST_TIMEOUT_SECONDS = 10.0 +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 + +# Mirrors the caps the endpoint enforces, so an oversized tree falls back +# locally instead of collecting a 400 from the server. +MAX_REMOTE_FILES = 256 +MAX_REMOTE_FILE_BYTES = 256 * 1024 +MAX_REMOTE_CONTENT_BYTES = 2 * 1024 * 1024 + + +class RemoteUnavailable(Exception): + """The server could not be used, so the offline floor has to run.""" + + +class UnsupportedRemoteVersion(Exception): + """The server does not publish the version this tree declares. + + This is not a fallback case. Measuring a version nobody recognizes against + v1alpha1 rules would bury the one useful fact under invented unknown-field + reports. + """ + + +class Outcome: + """What ran, what it found, and what it deliberately did not check.""" + + def __init__( + self, + mode: str, + schema_version: str, + problems: list[Problem], + deferred: Optional[list[dict[str, Any]]] = None, + fallback_reason: str = "", + ): + self.mode = mode + self.schema_version = schema_version + self.problems = problems + self.deferred = deferred or [] + self.fallback_reason = fallback_reason + + def disclosure(self) -> str: + """The sentence the agent must repeat. Never claim more than ran.""" + if self.mode == "remote": + return ( + f"Validated with the warp-server parser for {self.schema_version}; " + "state-dependent apply checks were not run." + ) + if self.mode == "unsupported-version": + return ( + f"This tree declares {self.schema_version}, which was not validated; " + "check it against a server that publishes that version rather than " + "downgrading schemaVersion." + ) + return ( + f"Server validation was unavailable ({self.fallback_reason}); validated with " + f"the bundled offline {self.schema_version} fallback, which may be older than " + "the server. State-dependent apply checks were not run." + ) + + +def declared_schema_version(root: Path) -> str: + """Return the version factory.yaml declares, defaulting as the parser does.""" + factory_file = root / "factory.yaml" + if _leaves_factory_root(factory_file, root): + return SUPPORTED_SCHEMA_VERSION + try: + parsed = load_yaml(factory_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, YamlError): + return SUPPORTED_SCHEMA_VERSION + if not isinstance(parsed, dict): + return SUPPORTED_SCHEMA_VERSION + declared = parsed.get("schemaVersion") + if not isinstance(declared, str) or not declared.strip(): + return SUPPORTED_SCHEMA_VERSION + return declared.strip() + + +def server_root(argument: Optional[str]) -> str: + """Resolve the server to ask, so a local or staging root needs no code change.""" + chosen = argument or os.environ.get("WARP_SERVER_ROOT") or DEFAULT_SERVER_ROOT + return chosen.rstrip("/") + + +def _request_json(url: str, token: Optional[str] = None, payload: Optional[Any] = None) -> Any: + """Fetch or post JSON, turning every failure class into RemoteUnavailable.""" + data = None + headers = {"Accept": "application/json"} + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = "Bearer " + token + request = urllib.request.Request(url, data=data, headers=headers) + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: + body = response.read(MAX_RESPONSE_BYTES + 1) + except urllib.error.HTTPError as error: + raise RemoteUnavailable(f"the server answered HTTP {error.code}") from error + except Exception as error: # DNS, TLS, connection, timeout, proxy, ... + raise RemoteUnavailable(f"the server could not be reached: {error}") from error + if len(body) > MAX_RESPONSE_BYTES: + raise RemoteUnavailable("the server response was implausibly large") + try: + return json.loads(body.decode("utf-8")) + except (UnicodeError, ValueError) as error: + raise RemoteUnavailable(f"the server response was not JSON: {error}") from error + + +def _remote_payload(root: Path) -> tuple[list[dict[str, str]], list[Problem]]: + """Collect the tree to submit, refusing symlinks the way the server does.""" + files: list[dict[str, str]] = [] + problems: list[Problem] = [] + total = 0 + for absolute in _resource_files(root): + relative = absolute.relative_to(root).as_posix() + kind, _ = classify(relative) + if kind in ("unrelated", "skill", "invalid"): + continue + if _leaves_factory_root(absolute, root): + problems.append(Problem(relative, SYMLINK_REFUSED)) + continue + try: + content = absolute.read_text(encoding="utf-8") + except (OSError, UnicodeError) as error: + problems.append(Problem(relative, f"could not read UTF-8 resource: {error}")) + continue + encoded = len(content.encode("utf-8")) + if encoded > MAX_REMOTE_FILE_BYTES: + raise RemoteUnavailable(f"{relative} is larger than the endpoint accepts") + total += encoded + if total > MAX_REMOTE_CONTENT_BYTES or len(files) >= MAX_REMOTE_FILES: + raise RemoteUnavailable("the tree is larger than the endpoint accepts") + files.append({"path": relative, "content": content}) + if not files: + raise RemoteUnavailable("the tree has no resource files to submit") + return files, problems + + +def validate_with_server(root: Path, base_url: str, version: str) -> Outcome: + """Validate through the server, or raise so the caller falls back. + + Fetching the schema is not validation: a reachable registry with an + unusable validate endpoint still means the tree was never checked. + """ + token = os.environ.get("WARP_API_KEY") + if not token: + raise RemoteUnavailable("WARP_API_KEY is not set and the endpoint is authenticated") + + registry = _request_json(base_url + SCHEMA_REGISTRY_PATH) + if not isinstance(registry, dict) or not isinstance(registry.get("versions"), list): + raise RemoteUnavailable("the schema registry response was malformed") + published = { + entry.get("schema_version") for entry in registry["versions"] if isinstance(entry, dict) + } + if version not in published: + raise UnsupportedRemoteVersion(version) + + files, local_problems = _remote_payload(root) + response = _request_json(base_url + VALIDATE_PATH, token=token, payload={"files": files}) + if not isinstance(response, dict) or not isinstance(response.get("diagnostics"), list): + raise RemoteUnavailable("the validation response was malformed") + + problems = list(local_problems) + for diagnostic in response["diagnostics"]: + if not isinstance(diagnostic, dict): + raise RemoteUnavailable("the validation response was malformed") + code = str(diagnostic.get("code", "")) + message = str(diagnostic.get("message", "")) + line = diagnostic.get("line") + problems.append( + Problem( + str(diagnostic.get("path", "")), + f"{code}: {message}" if code else message, + line=line if isinstance(line, int) else None, + ) + ) + deferred = [ + entry for entry in response.get("deferred_resolutions", []) if isinstance(entry, dict) + ] + reported = response.get("schema_version") + return Outcome( + "remote", + reported if isinstance(reported, str) and reported else version, + problems, + deferred, + ) + + +def validate(root: Path, store: SchemaStore, base_url: str, offline: bool) -> Outcome: + """Validate against the server when it is usable, otherwise offline.""" + version = declared_schema_version(root) + fallback_reason = "--offline was requested" + if not offline: + try: + return validate_with_server(root, base_url, version) + except UnsupportedRemoteVersion: + return Outcome( + "unsupported-version", + version, + [ + Problem( + "factory.yaml", + f"the server does not publish schema version {version!r}, so this " + "tree was not validated; correct the version rather than " + "downgrading it to make a check pass", + pointer="schemaVersion", + ) + ], + ) + except RemoteUnavailable as error: + fallback_reason = str(error) + + if version != SUPPORTED_SCHEMA_VERSION: + # The offline floor may only judge the one version it describes. + return Outcome( + "unsupported-version", + version, + [ + Problem( + "factory.yaml", + f"these bundled schemas describe {SUPPORTED_SCHEMA_VERSION}, not " + f"{version!r}, so this tree was not validated locally; check it with " + "the server instead of downgrading schemaVersion", + pointer="schemaVersion", + ) + ], + ) + return Outcome("offline", version, validate_tree(root, store), fallback_reason=fallback_reason) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", nargs="?", default=".", help="Factory root containing factory.yaml") + parser.add_argument("--json", action="store_true", help="emit machine-readable output") + parser.add_argument( + "--offline", + action="store_true", + help="skip the server and use the bundled schemas, which may be older", + ) + parser.add_argument( + "--server-root", + default=None, + help="warp-server root to validate against; defaults to $WARP_SERVER_ROOT then " + + DEFAULT_SERVER_ROOT, + ) + parser.add_argument( + "--schemas", + default=str(Path(__file__).resolve().parent.parent / "schemas"), + help="directory holding the bundled JSON Schemas", + ) + args = parser.parse_args() + + root = Path(args.root).resolve() + store = SchemaStore(Path(args.schemas).resolve()) + outcome = validate(root, store, server_root(args.server_root), args.offline) + problems = outcome.problems + + if args.json: + print( + json.dumps( + { + "valid": not problems, + "validated_with": outcome.mode, + "schema_version": outcome.schema_version, + "disclosure": outcome.disclosure(), + "problems": [problem.as_dict() for problem in problems], + "deferred_resolutions": outcome.deferred, + }, + indent=2, + ) + ) + return 1 if problems else 0 + + if problems: + print(f"{len(problems)} problem(s) in {root}:", file=sys.stderr) + for problem in problems: + print(f" {problem.render()}", file=sys.stderr) + print(outcome.disclosure(), file=sys.stderr) + return 1 + + print(f"{root}: factory files are valid.") + print(outcome.disclosure()) + for entry in outcome.deferred: + print( + f" deferred: {entry.get('path', '')} {entry.get('field', '')} " + f"({entry.get('kind', '')}) is resolved when the plan is applied" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/oz-harness-support/tests/test-factory-files.sh b/plugins/oz-harness-support/tests/test-factory-files.sh new file mode 100755 index 0000000..a3acac8 --- /dev/null +++ b/plugins/oz-harness-support/tests/test-factory-files.sh @@ -0,0 +1,105 @@ +#!/bin/bash +# +# Smoke test for the mirrored factory-files skill. +# +# The skill tree under skills/factory-files is a byte-for-byte copy of +# resources/bundled/skills/factory-files in warpdotdev/warp at the commit named +# in this plugin's README. Its own regression corpus lives there; this checks +# the one thing the copy can get wrong on its way over, which is arriving +# incomplete or unrunnable. +# +# The validator prefers warp-server and falls back to the bundled schemas, so +# every run here passes --offline: CI has no server and no API key, and the +# offline floor is what a mirror needs to guarantee. + +set -uo pipefail + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SKILL="$PLUGIN_ROOT/skills/factory-files" +VALIDATOR="$SKILL/scripts/validate_factory_files.py" + +PASSED=0 +FAILED=0 + +pass() { + echo " ✓ $1" + PASSED=$((PASSED + 1)) +} + +fail() { + echo " ✗ $1" + [ -n "${2:-}" ] && echo " $2" + FAILED=$((FAILED + 1)) +} + +echo "factory-files mirror" + +for required in \ + "$SKILL/SKILL.md" \ + "$VALIDATOR" \ + "$SKILL/schemas/common.schema.json" \ + "$SKILL/schemas/factory.schema.json" \ + "$SKILL/schemas/agent.schema.json" \ + "$SKILL/schemas/automation.schema.json" \ + "$SKILL/schemas/runner.schema.json" \ + "$SKILL/schemas/scorer.schema.json" \ + "$SKILL/references/examples.md" \ + "$SKILL/references/scorers.md" \ + "$SKILL/references/validation.md"; do + if [ -f "$required" ]; then + pass "present: ${required#"$SKILL/"}" + else + fail "missing: ${required#"$SKILL/"}" + fi +done + +if ! command -v python3 >/dev/null 2>&1; then + fail "python3 is required to run the bundled validator" + echo " $PASSED passed, $FAILED failed" + exit 1 +fi + +WORKSPACE="$(mktemp -d)" +trap 'rm -rf "$WORKSPACE"' EXIT + +mkdir -p "$WORKSPACE/valid/agents/main" "$WORKSPACE/invalid/agents/main" +cat >"$WORKSPACE/valid/factory.yaml" <<'YAML' +schemaVersion: v1alpha1 +name: mirror-smoke +repositories: + - owner: warpdotdev + name: warp +agentDefaults: + model: auto +YAML +printf -- '---\nagentType: MAIN\n---\nDo the thing.\n' >"$WORKSPACE/valid/agents/main/agent.md" + +# The same tree with no MAIN agent, which the tree-level rules must reject. +cp "$WORKSPACE/valid/factory.yaml" "$WORKSPACE/invalid/factory.yaml" +printf -- '---\ndescription: no main agent\n---\nDo the thing.\n' >"$WORKSPACE/invalid/agents/main/agent.md" + +output="$(python3 "$VALIDATOR" "$WORKSPACE/valid" --offline 2>&1)" +if [ $? -eq 0 ]; then + pass "a valid tree is accepted offline" +else + fail "a valid tree was rejected offline" "$output" +fi + +case "$output" in + *"Server validation was unavailable"*) + pass "the offline fallback is disclosed" + ;; + *) + fail "the offline fallback was not disclosed" "$output" + ;; +esac + +output="$(python3 "$VALIDATOR" "$WORKSPACE/invalid" --offline 2>&1)" +if [ $? -ne 0 ]; then + pass "a tree with no MAIN agent is rejected offline" +else + fail "a tree with no MAIN agent was accepted offline" "$output" +fi + +echo " $PASSED passed, $FAILED failed" +[ "$FAILED" -eq 0 ] || exit 1 From a857104c564485c11adc96d1c6e0b184a3ff22a9 Mon Sep 17 00:00:00 2001 From: advait-m Date: Mon, 17 Aug 2026 03:31:38 -0400 Subject: [PATCH 2/2] Mirror the server-only factory-files skill Re-mirrors warpdotdev/warp f6f4ceac8, which removes the skill's local copy of the Factory file format. The bundled schemas went stale inside a release and rejected Linear and Slack trigger aliases that warp-server accepts, so an agent clearing those diagnostics would delete working configuration. The skill now uploads the tree to warp-server and relays its verdict, and reports that a tree was not checked when the server cannot be reached. The smoke test drops --offline, which no longer exists. It checks the copy arrived complete, carries no schemas, and reports a missing verdict rather than claiming one. Co-Authored-By: Warp --- plugins/oz-harness-support/README.md | 17 +- .../skills/factory-files/SKILL.md | 91 +- .../factory-files/references/scorers.md | 11 +- .../factory-files/references/validation.md | 191 +-- .../factory-files/schemas/agent.schema.json | 24 - .../schemas/automation.schema.json | 929 ----------- .../factory-files/schemas/common.schema.json | 190 --- .../factory-files/schemas/factory.schema.json | 118 -- .../factory-files/schemas/runner.schema.json | 135 -- .../factory-files/schemas/scorer.schema.json | 74 - .../scripts/validate_factory_files.py | 1486 ++--------------- .../tests/test-factory-files.sh | 78 +- 12 files changed, 310 insertions(+), 3034 deletions(-) delete mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json delete mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json delete mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json delete mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json delete mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json delete mode 100644 plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json mode change 100755 => 100644 plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py diff --git a/plugins/oz-harness-support/README.md b/plugins/oz-harness-support/README.md index 498cc0b..3f7b645 100644 --- a/plugins/oz-harness-support/README.md +++ b/plugins/oz-harness-support/README.md @@ -27,15 +27,22 @@ The plugin ships skills the agent uses to talk to the Oz platform: `skills/factory-files` is a byte-for-byte copy of `resources/bundled/skills/factory-files` in [warpdotdev/warp](https://github.com/warpdotdev/warp), mirrored at commit -`85e89ea`. Warp bundles that skill for its own clients; Claude Code loads +`f6f4ceac8`. Warp bundles that skill for its own clients; Claude Code loads filesystem skills from this plugin instead, so it is copied here rather than resolved from a bundle. Change it in `warpdotdev/warp` and re-mirror; edits made here are lost on the next sync. -The skill validates against warp-server when it is reachable and falls back to -the schemas bundled beside it otherwise, so a copy that lags the server still -works and says that it lagged. `tests/test-factory-files.sh` checks the copy -arrived complete and runs; its regression corpus lives in Warp. +The skill validates only against warp-server, which owns the Factory file +format. It carries no copy of that format: a bundled copy ships inside a +release, goes stale, and then reports valid fields as unknown, which invites an +agent to delete working configuration. When the server cannot be reached the +skill reports that the tree was not checked rather than guessing. + +That also keeps this mirror cheap. There is no schema here to drift, so a stale +copy costs a stale workflow document, not a wrong verdict. +`tests/test-factory-files.sh` checks the copy arrived complete, carries no +schemas, and reports a missing verdict correctly; its behavioural corpus lives +in Warp. ## Requirements diff --git a/plugins/oz-harness-support/skills/factory-files/SKILL.md b/plugins/oz-harness-support/skills/factory-files/SKILL.md index c0721a5..abe549d 100644 --- a/plugins/oz-harness-support/skills/factory-files/SKILL.md +++ b/plugins/oz-harness-support/skills/factory-files/SKILL.md @@ -10,10 +10,10 @@ authoring and editing those files, and validating them before you open a pull request. warp-server owns the format. It publishes the schema for each version it -supports and validates a tree with the same parser the apply path uses, so ask -it rather than reasoning from a copy that ships inside a Warp release. This -skill bundles schemas and a validator as an offline floor for when the server -cannot be reached, and always says which of the two ran. +supports and validates a tree with the same parser the apply path uses. This +skill carries no copy of the format: a copy ships inside a Warp release, goes +stale against the server, and then reports confident, wrong diagnostics. When +the server cannot be reached, the answer is that the tree was not checked. Use this skill for repository files. It is not the skill for operating a live factory: use `factory-mcp` to send work to a factory, inspect task status, or @@ -94,38 +94,40 @@ can contain spaces. python3 "{{skill_dir}}/scripts/validate_factory_files.py" "" ``` -It asks the server first and falls back to the bundled copy on its own. Add -`--json` for machine-readable output, `--server-root ` to point at a -local, staging, or self-hosted server, and `--offline` to skip the server -deliberately. `WARP_SERVER_ROOT` sets the root too. The validation endpoint is -authenticated and reads `WARP_API_KEY`, which agent sandboxes already carry. +It selects the tree's resource files and submits them to the server, which runs +the real parser. Add `--json` for machine-readable output and `--server-root +`, or `WARP_SERVER_ROOT`, to point at a local, staging, or self-hosted +server. No credential is required; `WARP_API_KEY` is forwarded when the +environment already carries one, as an agent sandbox does. -A non-zero exit means at least one problem; fix every reported problem and -re-run until it is clean. +The exit code distinguishes three outcomes, and so must you: -If no Python 3 interpreter is available, do not install one or claim the tree -was validated without the user's approval. Check the changed document against -the fetched schema by hand and report that automated validation was -unavailable. - -### Say which validation ran -The validator prints one of two sentences. Repeat it; do not paraphrase it -away. +- `0` the server checked the tree and found no problem. +- `1` the server checked the tree and reported diagnostics. Fix every one and + re-run until it is clean. +- `2` the tree was **not** checked. This is not a pass and not a failure; it + says nothing about the files at all. -- Server: the tree went through warp-server's own parser for its declared - version. State-dependent apply checks still did not run. -- Offline: the server was unreachable, unauthenticated, or answered unusably, - so the bundled copy ran instead. That copy can be older than the server, so a - pass is weaker evidence than it looks. +### Never imply a check that did not happen +On exit `2`, say plainly that validation did not run and why. Do not describe +the files as valid, correct, or ready, and do not substitute your own reading +of the schema for a verdict. If you cannot reach a server and the change +matters, say so and let the user decide. -Never present an offline pass as a server verdict, and never treat a successful -schema fetch as validation on its own. +On exit `0`, repeat the sentence the validator prints rather than paraphrasing +it into something stronger. A pass means the parser and the state-independent +checks agreed; it does not mean the tree will apply. -Neither path resolves server state. Model IDs, environment IDs, secret names, +Validation resolves no server state. Model IDs, environment IDs, secret names, runner names, Scorer model IDs, MCP server IDs, integration availability, and the values of Linear and Slack name aliases are all checked when the plan is -applied. The server response lists what it did not check; report that -distinction rather than claiming a tree is fully verified. +applied. The response lists what it did not check, including any deferred name +aliases; report that distinction rather than claiming a tree is fully verified. + +If no Python 3 interpreter is available, do not install one or claim the tree +was validated without the user's approval. Check the changed document against +the fetched schema by hand and report that automated validation was +unavailable. When the Factory is already registered, a server plan remains the strongest available check. See `references/validation.md` for diagnostic codes and how to @@ -154,23 +156,14 @@ read them. name objects the server looks up at apply time, so they take a plain list of names rather than an `in`/`not_in` matcher. -## When the bundled copy and the server disagree -The server is right. The bundled schemas and validator ship inside your Warp -version, so they can be older than the server the Factory syncs against, and -they are deliberately permissive to avoid rejecting what a newer server -accepts. - -- Never delete, rename, or rewrite a field only because the offline validator - calls it unknown. On a file you did not author, that is at least as likely to - be a newer field as a mistake. Leave it, and say the bundled copy may be - behind. -- Treat unknown-field reports on your own new edits as real. You are the one - who just introduced the field. -- If the offline validator reports that it does not describe the tree's - `schemaVersion`, it stopped rather than applying `v1alpha1` rules to a format - it does not know. Validate against the server instead. - -If you are editing the bundled schemas themselves rather than a Factory tree, -their openness is deliberate and load-bearing, and it is not the policy the -server's own schemas follow. Read the "If you are changing these schemas" -section of `references/validation.md` before tightening anything. +## Do not add a local copy of the format +It is tempting to bundle the schema, or to reimplement a few checks here so +authoring works offline. Both have been tried and removed. A copy inside a Warp +release is routinely older than the server it is used against, and a stale copy +does not fail quietly: it reports a valid field as unknown, and an agent trying +to get to a clean run deletes working configuration to satisfy it. That has +already happened once, to Linear and Slack trigger aliases the server accepts. + +Reporting that a tree was not checked costs a little. Reporting the wrong +answer costs correct configuration. Fetch the format when you need it; say +nothing when you cannot. diff --git a/plugins/oz-harness-support/skills/factory-files/references/scorers.md b/plugins/oz-harness-support/skills/factory-files/references/scorers.md index 7a6ae38..2e47b8f 100644 --- a/plugins/oz-harness-support/skills/factory-files/references/scorers.md +++ b/plugins/oz-harness-support/skills/factory-files/references/scorers.md @@ -33,8 +33,7 @@ finishing. Return exactly one declared label. Names are trimmed and must be unique. - `enabled` — optional boolean, default true. Use `enabled: false` rather than a zero sampling rate to pause scoring. -- `output` — optional output form. `classification` is the current known form; - preserve newer values for forward compatibility. +- `output` — optional output form. `classification` is the current known form. - `labels` — required non-empty list of classifications; the current server accepts at most 20. Each label requires a non-empty `value` and a numeric `score` from 0 through 1; `description` is optional. Label values are @@ -46,6 +45,8 @@ finishing. Return exactly one declared label. - `model` — required model ID. The server validates availability. - `selfImprovement` — optional boolean, default false. -The Markdown body must not be empty. Scorer fields are forward-compatible: -preserve unknown fields rather than deleting them to satisfy an older bundled -schema. +The Markdown body must not be empty. + +The field list above is a summary for authoring; the server's schema is the +contract. If a Scorer already contains a field this page does not mention, +leave it alone and validate against the server rather than deleting it. diff --git a/plugins/oz-harness-support/skills/factory-files/references/validation.md b/plugins/oz-harness-support/skills/factory-files/references/validation.md index 9aeab41..07ce4f4 100644 --- a/plugins/oz-harness-support/skills/factory-files/references/validation.md +++ b/plugins/oz-harness-support/skills/factory-files/references/validation.md @@ -1,93 +1,87 @@ # Validating and reading diagnostics ## Layers of validation -1. **The server's parser** (`logic/factoryfile` in `warp-server`) is the +1. **The server's parser** (`logic/factoryfile` in `warp-server`) is the only authority. `POST /api/v1/factory-files/validate` runs it over a tree you submit as paths and content, and adds the state-independent rules the apply path enforces next: runner platforms and instance shapes, and trigger filter keys and matchers. Its diagnostics carry `FF_*` codes with a path, line, and column. -2. **The bundled validator** (`scripts/validate_factory_files.py`) is the - offline floor. It checks the files against the JSON Schemas shipped beside - it plus the tree-level rules, needs nothing but Python 3, and runs when the - server cannot be reached. It can be older than the server. -3. **Resolution and apply** validate everything that needs server state: model +2. **Resolution and apply** validate everything that needs server state: model IDs, environment IDs, secret names, runner names, MCP server IDs, integration providers, harness model catalogues, worker-host entitlement, and the values of Linear and Slack name aliases. -A clean result from either of the first two means the files pass the structural -and state-independent semantic checks. It does not mean the plan will apply. -The server response lists the checks it did not run; say so rather than -overstating what was checked. +There is no third layer, and deliberately no local one. A clean result from the +endpoint means the files pass the structural and state-independent semantic +checks. It does not mean the plan will apply. The response lists the checks it +did not run; say so rather than overstating what was checked. ## Running the validator The script lives at `scripts/validate_factory_files.py` inside this skill's -directory; `SKILL.md` shows its resolved path. +directory; `SKILL.md` shows its resolved path. It does not parse the format. It +selects the tree's resource files by path, refuses symlinks, submits the bytes, +and relays what comes back. ```bash python3 "/scripts/validate_factory_files.py" "" python3 "/scripts/validate_factory_files.py" "" --json -python3 "/scripts/validate_factory_files.py" "" --offline ``` -It reads the tree's `schemaVersion`, confirms the server publishes it, and -submits the tree; only if that fails does it fall back. `--server-root ` -or `WARP_SERVER_ROOT` selects a local, staging, or self-hosted server; -`WARP_API_KEY` authenticates the validation endpoint. `--offline` skips the -server deliberately. - -Every run ends with a sentence naming the path that ran, and `--json` carries -the same fact in `validated_with`. Repeat it. An offline pass is not a server -verdict, and a successful schema fetch is not validation. - -A version the server does not publish stops the run rather than being measured -against another version's rules. Correct the version; never lower it to make a -check pass. - -Use Python 3.8 or newer via the host's command (`python3`, `python`, or `py -3`). If none is -available, do not install an interpreter or claim automated validation without -the user's approval; inspect the changed document against its JSON Schema and -report the validation gap. - -Exit code 0 means no problems. Each problem reports the file, the field path, -and what is wrong. Fix them all and re-run; do not stop at the first one, since -one wrong field often produces several messages. -The bundled reader handles the canonical YAML forms this skill emits, not every -piece of YAML syntax accepted by `gopkg.in/yaml.v3`. If it cannot read an -existing file that the server accepts, do not normalize or rewrite the file -merely for the reader; report that local validation was unavailable and use a -server plan when possible. - -A resource file that is a symlink is reported and not read. The server parses -the repository tree, where a symlink is stored as its target path rather than -its target's content, so it never follows one either; a Factory resource has to -be a real file. Reading the target locally would also let a repository aim a -resource at any readable path on the machine. - -The schemas are ordinary JSON Schema 2020-12 documents, so any standard -validator works too if the tree is already converted to JSON. That applies to -the documents the server serves as well; those are exact for the version they -describe. `x-warp-*` annotations carry constraints JSON Schema cannot express -portably, such as trimmed Unicode alias rules and the power-of-two Linux -compute sizes; only a Warp validator enforces those annotations. +`--server-root ` or `WARP_SERVER_ROOT` selects a local, staging, or +self-hosted server. The endpoint needs no credential; `WARP_API_KEY` is +forwarded when the environment already has one, which makes the request +attributable inside an agent sandbox. + +Use Python 3.8 or newer via the host's command (`python3`, `python`, or +`py -3`). If none is available, do not install an interpreter or claim +automated validation without the user's approval; inspect the changed document +against the fetched schema and report the gap. + +## The three outcomes +- `0` — the server checked the tree and found no problem. +- `1` — the server checked the tree and reported diagnostics. +- `2` — the tree was **not** checked. + +Exit `2` is not a pass and not a failure. It happens when the server is +unreachable, answers with an error or a malformed body, the directory is not a +Factory root, or the tree is larger than the endpoint accepts. In every case +the correct report is that validation did not run, with the reason. Saying +anything about whether the files are correct would be inventing a verdict. + +With `--json`, `validated` distinguishes the cases: a run that reached no +verdict carries `validated: false` and no `valid` key at all, so there is +nothing to misread. + +Each problem reports the file, the field path, and what is wrong. Fix them all +and re-run; do not stop at the first one, since one wrong field often produces +several messages. + +A resource file that is a symlink is reported and never uploaded. The server +parses the repository tree, where a symlink is stored as its target path rather +than its target's content, so it never follows one either; a Factory resource +has to be a real file. Reading the target locally would also let a repository +aim a resource at any readable path on the machine. ## Deferred resolutions -A server response can carry `deferred_resolutions` alongside its diagnostics. A +A response can carry `deferred_resolutions` alongside its diagnostics. A deferred entry is not a problem: it names an authored value the endpoint deliberately did not prove, because proving it needs provider state. Linear and Slack name aliases are the current case — the endpoint checks that `teams` or `channels` is a list of non-empty names applicable to that event, and leaves whether those names exist to apply time. +Report deferred entries. They are the difference between "this parses" and +"this will work". + ## Diagnostic codes The server reports these from the validation endpoint and when a plan is run against a registered Factory. - `FF_MISSING_FACTORY` — no `factory.yaml` at the Factory root. - `FF_UNSUPPORTED_VERSION` — `schemaVersion` names no registered tree adapter. - The bundled validator reports an unrecognized version and stops rather than - applying v1alpha1 rules to a tree it does not describe. + The server stops rather than applying another version's rules. Correct the + version; never lower it to make a check pass. - `FF_UNSUPPORTED_PATH` — a file that resembles an Agent, Automation, Runner, or Scorer resource is at a non-canonical path. Other unrelated files under those directories are intentionally ignored. @@ -102,7 +96,7 @@ against a registered Factory. - `FF_ANCHOR`, `FF_ALIAS`, `FF_TAG` — YAML anchors, aliases, and explicit tags are not permitted. - `FF_UNKNOWN_FIELD` — a field the schema does not define. Check spelling and - the field reference; do not add the field to the schema to make it pass. + the fetched schema. - `FF_MISSING_REQUIRED` — a required field is absent or empty. - `FF_TYPE_MISMATCH` — a value has the wrong YAML type. - `FF_INVALID_VALUE` — a value violates a format or exclusivity rule, such as @@ -115,59 +109,46 @@ against a registered Factory. schedule on a non-schedule trigger, or a `schedule.cron_fired` trigger that declares both or neither of `schedule.cron` and `filter.schedule_ids`. - `FF_INVALID_EVENT`, `FF_INVALID_FILTER` — the event is unknown, or a filter - value is outside its valid domain. - -The bundled schemas do not reproduce every catalogue rejection above. Unknown -properties, agent types, credential strategies, harnesses and their per-harness -capabilities, integration types, trigger providers and events, runner platform -values, Scorer output forms, and server-tunable limits such as the Scorer label -cap are all preserved so an older client does not reject source accepted by a -newer server. A server plan is authoritative. - -Filter keys are the one catalogue still checked, because a misspelled key is a -common mistake that otherwise survives until apply. The check applies only when -both the provider and the event are ones these schemas know; a newer provider, -or a newer event on a known provider, leaves its filter unconstrained. - -What the bundled validator still refuses is what stays wrong under any of those -changes: malformed YAML and frontmatter, missing required fields, values of the -wrong type, references to Agents the tree does not declare, more or fewer than -one `MAIN`/`FOREMAN` Agent, duplicate resource names and labels, an empty -Scorer rubric, a label set that cannot both pass and fail, and filters that can -never match. - -## If you are changing these schemas -The permissiveness above is load-bearing, not an unfinished edge. These files -ship inside a Warp release and are routinely older than the `warp-server` they -run against, so closing them back up would reject configuration a newer server -accepts and push agents to delete working fields. - -When the format gains a value, add it to the relevant `x-warp-known-values` or -`x-warp-known-max-items` annotation. Do not turn an annotation back into -`enum`, `const`, `maxItems`, or `additionalProperties: false`. The regression -corpus in `script/test_factory_files_skill.py` asserts several of these -tolerances on purpose; if one starts failing, a schema was tightened. + key or value is outside its valid domain. -## Fixing a diagnostic -Change the file the diagnostic names, at the field it names. Do not silence a -diagnostic by deleting the resource, loosening the schema, or moving a file to -a path the parser ignores. +## Fetching the schema +The schema endpoints are unauthenticated and cacheable: + +```bash +curl -s https://app.warp.dev/api/v1/factory-files/schemas +curl -s https://app.warp.dev/api/v1/factory-files/schemas/v1alpha1 +``` + +They are ordinary JSON Schema 2020-12 documents, exact for the version they +describe, so any standard validator works if a tree is already converted to +JSON. `x-warp-*` annotations carry constraints JSON Schema cannot express +portably, such as trimmed Unicode alias rules and the power-of-two Linux +compute sizes; only a Warp validator enforces those. -If a diagnostic contradicts these references, the server is right. Say that the -bundled schemas look stale and, where you can, point at what changed in -`logic/factoryfile`. +Fetching a schema is not validation. A successful fetch says the server is +reachable, nothing more. -## Version skew -The schemas ship inside the Warp version running them, not from the server, so -they can lag the server that a Factory actually syncs against. Cloud agent runs -track releases closely; an installed desktop client can be much older. +## Why there is no offline mode +This skill used to bundle the schemas and a local validator so authoring worked +without a server. That was removed, and should not be reintroduced. -The asymmetry matters when reading an `unknown field` report: +The copy shipped inside a Warp release, so it was routinely older than the +server a Factory syncs against. A stale copy does not degrade gracefully: it +reports a field the server accepts as unknown, and an agent trying to reach a +clean run resolves that by deleting working configuration. It happened — an +earlier revision rejected the Linear and Slack trigger aliases (`teams`, +`projects`, `states`, `issues`, `channels`, `users`, `itemUsers`) that the +apply path rewrites and accepts, on a tree taken from the server's own +`testdata/valid`. -- On a field you just wrote, it is almost certainly a mistake. Fix it. -- On a field that was already in the file, it may be a newer field your copy of - the schemas does not know. Leave it alone and report the possibility. Removing - it would silently drop working configuration. +The trade is deliberate: never checking is recoverable, and the report says so. +Checking wrongly costs correct configuration and is not obviously wrong to the +agent acting on it. + +## Fixing a diagnostic +Change the file the diagnostic names, at the field it names. Do not silence a +diagnostic by deleting the resource or moving a file to a path the parser +ignores. ## Checking against the parser directly When `warp-server` is checked out locally, its parser tests are the closest @@ -175,7 +156,7 @@ thing to ground truth. Run them from that checkout, not from the Factory repository: ```bash -go test ./logic/factoryfile +go test ./logic/factoryfile/... ``` Fixtures under `logic/factoryfile/testdata` show accepted and rejected trees. diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json deleted file mode 100644 index 6bf0b8e..0000000 --- a/plugins/oz-harness-support/skills/factory-files/schemas/agent.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "agent.schema.json", - "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", - "title": "agents//agent.md frontmatter (v1alpha1)", - "description": "YAML frontmatter of an Agent file. The Agent's name comes from its directory, never from a field. The Markdown body after the closing fence is the Agent's prompt.", - "type": "object", - "additionalProperties": true, - "properties": { - "description": { - "type": ["string", "null"] - }, - "agentType": { "$ref": "common.schema.json#/$defs/agentType" }, - "credentialStrategy": { "$ref": "common.schema.json#/$defs/credentialStrategy" }, - "model": { "$ref": "common.schema.json#/$defs/ozModelOverride" }, - "harness": { "$ref": "common.schema.json#/$defs/harnessOverride" }, - "runner": { "$ref": "common.schema.json#/$defs/runnerRef" }, - "environmentId": { "$ref": "common.schema.json#/$defs/environmentId" }, - "secrets": { "$ref": "common.schema.json#/$defs/secrets" }, - "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, - "workerHost": { "$ref": "common.schema.json#/$defs/workerHost" } - }, - "allOf": [{ "$ref": "common.schema.json#/$defs/modelXorHarnessOptional" }] -} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json deleted file mode 100644 index b83c738..0000000 --- a/plugins/oz-harness-support/skills/factory-files/schemas/automation.schema.json +++ /dev/null @@ -1,929 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "automation.schema.json", - "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", - "title": "automations//automation.md frontmatter (v1alpha1)", - "description": "YAML frontmatter of an Automation file. The Automation's name comes from its directory, never from a field. The Markdown body after the closing fence is the run prompt.", - "type": "object", - "required": ["triggers"], - "additionalProperties": true, - "properties": { - "enabled": { - "type": "boolean", - "description": "Defaults to true when omitted." - }, - "agent": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "description": "Name of a declared Agent. Defaults to the MAIN/FOREMAN Agent when omitted." - }, - "model": { "$ref": "common.schema.json#/$defs/ozModelOverride" }, - "harness": { "$ref": "common.schema.json#/$defs/harnessOverride" }, - "runner": { "$ref": "common.schema.json#/$defs/runnerRef" }, - "environmentId": { "$ref": "common.schema.json#/$defs/environmentId" }, - "secrets": { "$ref": "common.schema.json#/$defs/secrets" }, - "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, - "workerHost": { "$ref": "common.schema.json#/$defs/workerHost" }, - "triggers": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/trigger" } - } - }, - "allOf": [{ "$ref": "common.schema.json#/$defs/modelXorHarnessOptional" }], - "$defs": { - "stringMatcher": { - "description": "A bare array is sugar for {in: [...]}. Within a field, in matches ANY-of and not_in excludes ANY-of. Null and an empty matcher impose nothing.", - "anyOf": [ - { "type": "null" }, - { "type": "array", "items": { "type": "string" } }, - { - "type": "object", - "additionalProperties": true, - "properties": { - "in": { "type": "array", "items": { "type": "string" } }, - "not_in": { "type": "array", "items": { "type": "string" } } - } - } - ] - }, - "intMatcher": { - "anyOf": [ - { "type": "null" }, - { "type": "array", "items": { "type": "integer" } }, - { - "type": "object", - "additionalProperties": true, - "properties": { - "in": { "type": "array", "items": { "type": "integer" } }, - "not_in": { "type": "array", "items": { "type": "integer" } } - } - } - ] - }, - "scheduleIdsMatcher": { - "description": "schedule_ids supports only the in operator: an exclusion would match every other schedule in scope.", - "anyOf": [ - { "type": "null" }, - { "type": "array", "items": { "type": "string" } }, - { - "type": "object", - "additionalProperties": true, - "properties": { - "in": { "type": "array", "items": { "type": "string" } } - } - } - ] - }, - "stringAliasList": { - "description": "A provider name alias takes a bare list of names, never a matcher object: the server resolves the names to durable IDs before the filter is canonicalized.", - "type": "array", - "items": { "$ref": "common.schema.json#/$defs/nonEmptyString" } - }, - "intAliasList": { - "description": "A provider number alias takes a bare list of positive numbers, never a matcher object.", - "type": "array", - "items": { "type": "integer", "minimum": 1 } - }, - "nonEmptyScheduleIds": { - "type": "object", - "required": ["filter"], - "properties": { - "filter": { - "type": "object", - "required": ["schedule_ids"], - "properties": { - "schedule_ids": { - "anyOf": [ - { "type": "array", "minItems": 1 }, - { - "type": "object", - "required": ["in"], - "properties": { "in": { "type": "array", "minItems": 1 } } - } - ] - } - } - } - } - }, - "inlineSchedule": { - "type": "object", - "required": ["cron"], - "additionalProperties": true, - "description": "A cron schedule declared inline on a schedule.cron_fired trigger. Only valid on that kind, and mutually exclusive with filter.schedule_ids.", - "properties": { - "name": { - "type": ["string", "null"], - "description": "Stable identity of this declaration within the Automation. At most one inline schedule may omit name. Changing name replaces the schedule; changing only cron updates it in place." - }, - "cron": { - "type": "string", - "pattern": "^\\s*(@(annually|yearly|monthly|weekly|daily|midnight|hourly)|@every\\s+\\S+|(\\S+\\s+){4}\\S+)\\s*$", - "description": "Standard five-field cron expression or a descriptor (@daily, @hourly, @every 1h). Always interpreted in UTC: a CRON_TZ= or TZ= prefix and the six-field seconds form are both rejected." - } - } - }, - "trigger": { - "type": "object", - "required": ["provider", "event"], - "additionalProperties": true, - "properties": { - "provider": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["github", "gitlab", "linear", "jira", "slack", "schedule", "factory"] - }, - "event": { "type": "string" }, - "filter": { - "type": "object", - "description": "Filter fields combine with AND. An absent field is a wildcard. The accepted keys depend on the (provider, event) pair." - }, - "schedule": { "$ref": "#/$defs/inlineSchedule" } - }, - "allOf": [ - { - "if": { - "required": ["provider"], - "properties": { "provider": { "const": "github" } } - }, - "then": { - "properties": { - "event": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": [ - "push", - "issue_created", - "issue_labeled", - "issue_mentioned", - "issue_assigned", - "pull_request_opened", - "pull_request_ready", - "pull_request_closed", - "pull_request_merged", - "pull_request_labeled", - "pull_request_synchronized", - "pull_request_reopened", - "pull_request_mentioned", - "pull_request_assigned", - "pull_request_review_requested", - "pull_request_review_submitted", - "check_suite_completed", - "check_suite_rerequested", - "check_run_rerequested", - "workflow_run_completed" - ] - } - } - } - }, - { - "if": { - "required": ["provider"], - "properties": { "provider": { "const": "gitlab" } } - }, - "then": { - "properties": { - "event": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["merge_request", "bot_mentioned"] - } - } - } - }, - { - "if": { - "required": ["provider"], - "properties": { "provider": { "const": "factory" } } - }, - "then": { - "properties": { - "event": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["work_item_stage_changed"] - } - } - } - }, - { - "if": { - "required": ["provider"], - "properties": { "provider": { "const": "linear" } } - }, - "then": { - "properties": { - "event": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": [ - "issue_created", - "issue_labeled", - "issue_state_changed", - "issue_assigned", - "comment_created", - "agent_session_created" - ] - } - } - } - }, - { - "if": { - "required": ["provider"], - "properties": { "provider": { "const": "jira" } } - }, - "then": { - "properties": { - "event": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": [ - "issue_created", - "issue_labeled", - "status_changed", - "agent_session_created" - ] - } - } - } - }, - { - "if": { - "required": ["provider"], - "properties": { "provider": { "const": "slack" } } - }, - "then": { - "properties": { - "event": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": [ - "app_mention", - "message_dm", - "message_im", - "message_mpim", - "message_posted", - "reaction_added", - "member_joined_channel" - ] - } - } - } - }, - { - "if": { - "required": ["provider"], - "properties": { "provider": { "const": "schedule" } } - }, - "then": { - "properties": { - "event": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["cron_fired"] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "const": "push" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "branches": { "$ref": "#/$defs/stringMatcher" }, - "paths": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "enum": ["issue_created", "issue_labeled"] } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" }, - "assignees": { "$ref": "#/$defs/stringMatcher" }, - "authors": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { - "enum": [ - "pull_request_opened", - "pull_request_ready", - "pull_request_closed", - "pull_request_merged", - "pull_request_labeled", - "pull_request_synchronized", - "pull_request_reopened" - ] - } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "baseBranches": { - "$ref": "#/$defs/stringMatcher", - "description": "Authoring alias for base_branches; the two are mutually exclusive." - }, - "prNumbers": { - "$ref": "#/$defs/intMatcher", - "description": "Authoring alias for pr_numbers; the two are mutually exclusive." - }, - "repos": { "$ref": "#/$defs/stringMatcher" }, - "base_branches": { "$ref": "#/$defs/stringMatcher" }, - "pr_numbers": { "$ref": "#/$defs/intMatcher" }, - "paths": { "$ref": "#/$defs/stringMatcher" }, - "assignees": { "$ref": "#/$defs/stringMatcher" }, - "authors": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" } - }, - "allOf": [ - { "not": { "required": ["baseBranches", "base_branches"] } }, - { "not": { "required": ["prNumbers", "pr_numbers"] } } - ] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "enum": ["issue_mentioned", "pull_request_mentioned"] } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "mentioned": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "enum": ["issue_assigned", "pull_request_assigned"] } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "assignees": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "const": "pull_request_review_requested" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "reviewers": { "$ref": "#/$defs/stringMatcher" }, - "reviewer_teams": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "const": "pull_request_review_submitted" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "mentioned": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" }, - "review_states": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "const": "check_suite_completed" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "conclusions": { "$ref": "#/$defs/stringMatcher" }, - "branches": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" }, - "authors": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { "const": "workflow_run_completed" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "conclusions": { "$ref": "#/$defs/stringMatcher" }, - "branches": { "$ref": "#/$defs/stringMatcher" }, - "workflows": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" }, - "authors": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "github" }, - "event": { - "enum": ["check_run_rerequested", "check_suite_rerequested"] - } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "gitlab" }, - "event": { "const": "merge_request" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "actions": { "$ref": "#/$defs/stringMatcher" }, - "base_branches": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "gitlab" }, - "event": { "const": "bot_mentioned" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "repos": { "$ref": "#/$defs/stringMatcher" }, - "mentioned": { - "$ref": "#/$defs/stringMatcher", - "description": "Server-seeded. Declaring it has no effect." - } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "factory" }, - "event": { "const": "work_item_stage_changed" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "stages": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "linear" }, - "event": { - "enum": [ - "issue_created", - "issue_labeled", - "issue_state_changed", - "issue_assigned" - ] - } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "teams": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for team_ids; the two are mutually exclusive." - }, - "projects": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for project_ids; the two are mutually exclusive." - }, - "states": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for state_ids; the two are mutually exclusive." - }, - "team_ids": { "$ref": "#/$defs/stringMatcher" }, - "project_ids": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" }, - "state_ids": { "$ref": "#/$defs/stringMatcher" }, - "assignee_ids": { "$ref": "#/$defs/stringMatcher" }, - "mentioned_user_ids": { "$ref": "#/$defs/stringMatcher" }, - "creator_ids": { "$ref": "#/$defs/stringMatcher" } - }, - "allOf": [ - { "not": { "required": ["team_ids", "teams"] } }, - { "not": { "required": ["project_ids", "projects"] } }, - { "not": { "required": ["state_ids", "states"] } } - ] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "linear" }, - "event": { "const": "comment_created" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "teams": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for team_ids; the two are mutually exclusive." - }, - "projects": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for project_ids; the two are mutually exclusive." - }, - "states": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for state_ids; the two are mutually exclusive." - }, - "issues": { - "$ref": "#/$defs/intAliasList", - "description": "Authoring alias for issue_ids; the two are mutually exclusive." - }, - "team_ids": { "$ref": "#/$defs/stringMatcher" }, - "project_ids": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" }, - "state_ids": { "$ref": "#/$defs/stringMatcher" }, - "issue_ids": { "$ref": "#/$defs/stringMatcher" }, - "mentioned_user_ids": { "$ref": "#/$defs/stringMatcher" }, - "creator_ids": { "$ref": "#/$defs/stringMatcher" } - }, - "allOf": [ - { "not": { "required": ["team_ids", "teams"] } }, - { "not": { "required": ["project_ids", "projects"] } }, - { "not": { "required": ["state_ids", "states"] } }, - { "not": { "required": ["issue_ids", "issues"] } } - ] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "linear" }, - "event": { "const": "agent_session_created" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "teams": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for team_ids; the two are mutually exclusive." - }, - "team_ids": { "$ref": "#/$defs/stringMatcher" }, - "creator_ids": { "$ref": "#/$defs/stringMatcher" }, - "keywords": { "$ref": "#/$defs/stringMatcher" } - }, - "allOf": [ - { "not": { "required": ["team_ids", "teams"] } } - ] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "jira" }, - "event": { "enum": ["issue_created", "issue_labeled"] } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "project_keys": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "jira" }, - "event": { "const": "status_changed" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "project_keys": { "$ref": "#/$defs/stringMatcher" }, - "status_ids": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "jira" }, - "event": { "const": "agent_session_created" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "project_keys": { "$ref": "#/$defs/stringMatcher" }, - "labels": { "$ref": "#/$defs/stringMatcher" }, - "keywords": { "$ref": "#/$defs/stringMatcher" } - } - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "slack" }, - "event": { - "enum": [ - "app_mention", - "message_dm", - "message_im", - "message_mpim", - "message_posted" - ] - } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "channels": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for channel_ids; the two are mutually exclusive." - }, - "users": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for user_ids; the two are mutually exclusive." - }, - "channel_ids": { "$ref": "#/$defs/stringMatcher" }, - "user_ids": { "$ref": "#/$defs/stringMatcher" }, - "keywords": { "$ref": "#/$defs/stringMatcher" } - }, - "allOf": [ - { "not": { "required": ["channel_ids", "channels"] } }, - { "not": { "required": ["user_ids", "users"] } } - ] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "slack" }, - "event": { "const": "reaction_added" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "channels": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for channel_ids; the two are mutually exclusive." - }, - "users": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for user_ids; the two are mutually exclusive." - }, - "itemUsers": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for item_user_ids; the two are mutually exclusive." - }, - "channel_ids": { "$ref": "#/$defs/stringMatcher" }, - "user_ids": { "$ref": "#/$defs/stringMatcher" }, - "emojis": { "$ref": "#/$defs/stringMatcher" }, - "keywords": { "$ref": "#/$defs/stringMatcher" }, - "item_user_ids": { "$ref": "#/$defs/stringMatcher" } - }, - "allOf": [ - { "not": { "required": ["channel_ids", "channels"] } }, - { "not": { "required": ["user_ids", "users"] } }, - { "not": { "required": ["itemUsers", "item_user_ids"] } } - ] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "slack" }, - "event": { "const": "member_joined_channel" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "channels": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for channel_ids; the two are mutually exclusive." - }, - "users": { - "$ref": "#/$defs/stringAliasList", - "description": "Authoring alias for user_ids; the two are mutually exclusive." - }, - "channel_ids": { "$ref": "#/$defs/stringMatcher" }, - "user_ids": { "$ref": "#/$defs/stringMatcher" } - }, - "allOf": [ - { "not": { "required": ["channel_ids", "channels"] } }, - { "not": { "required": ["user_ids", "users"] } } - ] - } - } - } - }, - { - "if": { - "required": ["provider", "event"], - "properties": { - "provider": { "const": "schedule" }, - "event": { "const": "cron_fired" } - } - }, - "then": { - "properties": { - "filter": { - "additionalProperties": false, - "properties": { - "schedule_ids": { "$ref": "#/$defs/scheduleIdsMatcher" } - } - } - }, - "oneOf": [ - { "required": ["schedule"] }, - { "$ref": "#/$defs/nonEmptyScheduleIds" } - ] - } - }, - { - "if": { - "not": { - "allOf": [ - { - "required": ["provider"], - "properties": { "provider": { "const": "schedule" } } - }, - { - "required": ["event"], - "properties": { "event": { "const": "cron_fired" } } - } - ] - } - }, - "then": { "not": { "required": ["schedule"] } } - } - ] - } - } -} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json deleted file mode 100644 index d794828..0000000 --- a/plugins/oz-harness-support/skills/factory-files/schemas/common.schema.json +++ /dev/null @@ -1,190 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "common.schema.json", - "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", - "title": "Shared Factory file definitions (v1alpha1)", - "description": "Definitions shared by factory.yaml, Agent, Automation, Scorer, and Runner documents.", - "$defs": { - "nonEmptyString": { - "type": "string", - "minLength": 1, - "pattern": "\\S", - "description": "A string that is non-empty after trimming surrounding whitespace." - }, - "nullableNonEmptyString": { - "type": ["string", "null"], - "if": { "type": "string" }, - "then": { - "minLength": 1, - "pattern": "\\S" - }, - "description": "A non-empty string override, or null to inherit." - }, - "clearableString": { - "type": ["string", "null"], - "description": "A string override. Null or empty explicitly clears an inherited value; omitting the field inherits it." - }, - "alias": { - "type": ["string", "null"], - "description": "Factory display handle, used as the @-mention name on integrated platforms. Letters, digits, spaces, '-', '_', and '.' only, at most 60 characters. Case is preserved; uniqueness is compared case-insensitively across the workspace.", - "x-warp-character-class": "unicode-letters-numbers-space-dot-underscore-hyphen", - "x-warp-max-trimmed-runes": 60 - }, - "credentialStrategy": { - "$ref": "#/$defs/nullableNonEmptyString", - "x-warp-known-values": ["EXECUTOR", "CREATOR"], - "description": "Whose credentials a run executes with. Omit to leave the value already stored on the server untouched." - }, - "agentType": { - "$ref": "#/$defs/nullableNonEmptyString", - "x-warp-known-values": ["CUSTOM", "MAIN", "FOREMAN", "TRIAGE", "SPEC", "IMPLEMENT", "REVIEW", "VERIFY"], - "description": "MAIN is an authoring alias for FOREMAN. Exactly one Agent in the tree must declare MAIN or FOREMAN. Omitting agentType resolves to CUSTOM." - }, - "secretName": { - "$ref": "#/$defs/nonEmptyString", - "description": "Name of a managed secret already registered for the team." - }, - "secrets": { - "type": "array", - "items": { "$ref": "#/$defs/secretName" }, - "description": "Managed secret names. Declaring the field at Agent or Automation level replaces the inherited list rather than adding to it." - }, - "uniqueSecrets": { - "type": "array", - "items": { "$ref": "#/$defs/secretName" }, - "uniqueItems": true, - "description": "Managed secret names. Duplicates are rejected in factory.yaml." - }, - "mcpServers": { - "type": "object", - "propertyNames": { "$ref": "#/$defs/nonEmptyString" }, - "additionalProperties": { - "type": "object", - "required": ["warpId"], - "additionalProperties": true, - "properties": { - "warpId": { - "$ref": "#/$defs/nonEmptyString", - "description": "Warp-managed MCP server ID. This is the only key an entry may carry." - } - } - }, - "description": "Managed MCP servers keyed by the name the agent sees. Declaring the field at Agent or Automation level replaces the inherited map." - }, - "harnessAuth": { - "type": ["object", "null"], - "description": "Credentials for a non-Oz harness. Null explicitly clears inherited auth.", - "additionalProperties": true, - "properties": { - "source": { - "$ref": "#/$defs/nonEmptyString", - "x-warp-known-values": ["managedSecret", "workerEnvironment"] - }, - "secretName": { "$ref": "#/$defs/nonEmptyString" } - }, - "if": { "type": "object" }, - "then": { - "required": ["source"], - "allOf": [ - { - "if": { - "properties": { "source": { "const": "managedSecret" } }, - "required": ["source"] - }, - "then": { "required": ["secretName"] } - }, - { - "if": { - "properties": { "source": { "const": "workerEnvironment" } }, - "required": ["source"] - }, - "then": { "not": { "required": ["secretName"] } } - } - ] - } - }, - "harnessCommonProperties": { - "type": "object", - "additionalProperties": true, - "properties": { - "type": { - "$ref": "#/$defs/nullableNonEmptyString", - "x-warp-known-values": ["oz", "claude", "claude-code", "codex", "gemini"], - "description": "Harness config name. claude-code is an accepted alias for claude." - }, - "model": { "$ref": "#/$defs/nullableNonEmptyString" }, - "reasoningLevel": { - "$ref": "#/$defs/clearableString", - "description": "The current server rejects this on the oz harness. Per-harness capabilities change, so the bundled schema leaves that judgement to the server." - }, - "auth": { "$ref": "#/$defs/harnessAuth" } - } - }, - "harnessOverride": { - "allOf": [ - { "$ref": "#/$defs/harnessCommonProperties" }, - { - "anyOf": [ - { - "required": ["type"], - "properties": { "type": { "type": "string" } } - }, - { - "required": ["model"], - "properties": { "model": { "type": "string" } } - }, - { "required": ["reasoningLevel"] }, - { "required": ["auth"] } - ] - } - ], - "description": "Sparse harness override. Must declare at least one of type, model, reasoningLevel, or auth." - }, - "harnessDefault": { - "allOf": [ - { "$ref": "#/$defs/harnessCommonProperties" }, - { - "required": ["type", "model"], - "properties": { - "type": { - "$ref": "#/$defs/nonEmptyString" - }, - "model": { "$ref": "#/$defs/nonEmptyString" } - } - } - ], - "description": "Factory-level harness default. Both type and model are required." - }, - "modelXorHarnessRequired": { - "oneOf": [ - { "required": ["model"] }, - { "required": ["harness"] } - ], - "description": "Declare exactly one of model (Oz harness shorthand) or harness (explicit harness block)." - }, - "modelXorHarnessOptional": { - "not": { "required": ["model", "harness"] }, - "description": "model and harness are mutually exclusive." - }, - "ozModelDefault": { - "$ref": "#/$defs/nonEmptyString", - "description": "Oz model ID. Shorthand for harness: {type: oz, model: }. Mutually exclusive with harness." - }, - "ozModelOverride": { - "$ref": "#/$defs/nullableNonEmptyString", - "description": "Oz model ID, or null to inherit. Shorthand for harness: {type: oz, model: }. Mutually exclusive with harness." - }, - "runnerRef": { - "$ref": "#/$defs/nullableNonEmptyString", - "description": "Name of a runner declared under runners/.yaml, or null to inherit." - }, - "environmentId": { - "$ref": "#/$defs/nullableNonEmptyString", - "description": "Cloud environment ID the run executes in, or null to inherit." - }, - "workerHost": { - "$ref": "#/$defs/clearableString", - "description": "Self-hosted worker host. Null or empty clears an inherited host and defers to the workspace default." - } - } -} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json deleted file mode 100644 index c004059..0000000 --- a/plugins/oz-harness-support/skills/factory-files/schemas/factory.schema.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "factory.schema.json", - "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", - "title": "factory.yaml (v1alpha1)", - "description": "The Factory root document. Exactly one factory.yaml must exist at the registered Factory root.", - "type": "object", - "required": ["schemaVersion", "name", "repositories", "agentDefaults"], - "additionalProperties": true, - "properties": { - "schemaVersion": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["v1alpha1"], - "description": "Tree schema version. These schemas describe v1alpha1. A tree declaring a newer version is reported as unvalidatable rather than validated against v1alpha1 rules." - }, - "name": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "description": "Factory name." - }, - "description": { - "type": ["string", "null"] - }, - "alias": { "$ref": "common.schema.json#/$defs/alias" }, - "credentialStrategy": { "$ref": "common.schema.json#/$defs/credentialStrategy" }, - "repositories": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "description": "GitHub repositories in the Factory's scope.", - "items": { - "type": "object", - "required": ["owner", "name"], - "additionalProperties": true, - "properties": { - "owner": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, - "name": { "$ref": "common.schema.json#/$defs/nonEmptyString" } - } - } - }, - "secrets": { "$ref": "common.schema.json#/$defs/uniqueSecrets" }, - "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, - "cloudProviders": { - "$ref": "#/$defs/cloudProviders", - "description": "Current cloud-provider identity federation section." - }, - "providers": { - "$ref": "#/$defs/cloudProviders", - "description": "Legacy read-only alias for cloudProviders. Author cloudProviders in new files." - }, - "integrations": { - "type": "array", - "uniqueItems": true, - "description": "Integration providers attached to the Factory. An empty list explicitly detaches every provider. github is not declarable here: repository access comes from repositories.", - "items": { - "type": "object", - "required": ["type"], - "additionalProperties": true, - "properties": { - "type": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["jira", "linear", "slack"], - "description": "Provider slug. The server lowercases and trims the value; author the canonical known spelling." - } - } - } - }, - "agentDefaults": { - "type": "object", - "additionalProperties": true, - "description": "Execution defaults every Agent inherits. Exactly one of model or harness is required.", - "properties": { - "model": { "$ref": "common.schema.json#/$defs/ozModelDefault" }, - "harness": { "$ref": "common.schema.json#/$defs/harnessDefault" }, - "runner": { "$ref": "common.schema.json#/$defs/runnerRef" }, - "environmentId": { "$ref": "common.schema.json#/$defs/environmentId" }, - "secrets": { "$ref": "common.schema.json#/$defs/secrets" }, - "mcpServers": { "$ref": "common.schema.json#/$defs/mcpServers" }, - "workerHost": { "$ref": "common.schema.json#/$defs/workerHost" } - }, - "allOf": [{ "$ref": "common.schema.json#/$defs/modelXorHarnessRequired" }] - } - }, - "$defs": { - "cloudProviders": { - "type": "object", - "additionalProperties": true, - "description": "Cloud provider identity federation used by agent runs.", - "properties": { - "gcp": { - "type": "object", - "required": [ - "projectNumber", - "workloadIdentityFederationPoolId", - "workloadIdentityFederationProviderId" - ], - "additionalProperties": true, - "properties": { - "projectNumber": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "description": "Quote the value so YAML keeps it a string rather than an integer." - }, - "workloadIdentityFederationPoolId": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, - "workloadIdentityFederationProviderId": { "$ref": "common.schema.json#/$defs/nonEmptyString" }, - "serviceAccountEmail": { "$ref": "common.schema.json#/$defs/nonEmptyString" } - } - }, - "aws": { - "type": "object", - "required": ["roleArn"], - "additionalProperties": true, - "properties": { - "roleArn": { "$ref": "common.schema.json#/$defs/nonEmptyString" } - } - } - } - } - } -} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json deleted file mode 100644 index f64e383..0000000 --- a/plugins/oz-harness-support/skills/factory-files/schemas/runner.schema.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "runner.schema.json", - "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", - "title": "runners/.yaml (v1alpha1)", - "description": "A Runner document. The Runner's name comes from the file name, never from a field. Platform and instance-shape rules are enforced when the plan is applied, not by the file parser.", - "type": "object", - "additionalProperties": true, - "properties": { - "description": { - "type": ["string", "null"] - }, - "setupCommands": { - "type": "array", - "items": { "type": "string" }, - "description": "Shell commands run while preparing the sandbox." - }, - "instanceShape": { - "type": "object", - "required": ["vcpus", "memoryGb"], - "additionalProperties": true, - "properties": { - "vcpus": { "type": "integer", "minimum": 1 }, - "memoryGb": { "type": "integer", "minimum": 1 } - } - }, - "platform": { - "type": "object", - "additionalProperties": true, - "properties": { - "os": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["linux", "macos"], - "description": "Defaults to linux when omitted." - }, - "arch": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["x86_64", "aarch64"], - "description": "Defaults to x86_64 on linux and aarch64 on macos. Supported pairs: linux/x86_64, linux/aarch64, macos/aarch64." - }, - "linux": { - "type": "object", - "additionalProperties": true, - "properties": { - "dockerImage": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "description": "Container image the Linux sandbox boots. Required for every Linux runner." - } - } - }, - "mac": { - "type": "object", - "required": ["version"], - "additionalProperties": true, - "properties": { - "version": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["14", "15", "26", "27"], - "description": "Quote the value so YAML keeps it a string. Defaults to 26 when the mac section is omitted." - } - } - } - } - } - }, - "allOf": [ - { - "if": { "$ref": "#/$defs/declaresMacOS" }, - "then": { - "properties": { - "platform": { - "not": { "required": ["linux"] } - } - } - } - }, - { - "if": { "$ref": "#/$defs/declaresLinuxOrDefault" }, - "then": { - "required": ["platform"], - "properties": { - "platform": { - "required": ["linux"], - "not": { "required": ["mac"] }, - "properties": { - "linux": { "required": ["dockerImage"] } - } - }, - "instanceShape": { "$ref": "#/$defs/linuxInstanceShape" } - } - } - } - ], - "$defs": { - "declaresMacOS": { - "type": "object", - "required": ["platform"], - "properties": { - "platform": { - "type": "object", - "required": ["os"], - "properties": { "os": { "const": "macos" } } - } - } - }, - "declaresLinuxOrDefault": { - "anyOf": [ - { "not": { "required": ["platform"] } }, - { - "required": ["platform"], - "properties": { - "platform": { "not": { "required": ["os"] } } - } - }, - { - "required": ["platform"], - "properties": { - "platform": { - "required": ["os"], - "properties": { "os": { "const": "linux" } } - } - } - } - ] - }, - "linuxInstanceShape": { - "required": ["vcpus", "memoryGb"], - "properties": { - "vcpus": { "type": "integer", "minimum": 1 }, - "memoryGb": { "type": "integer", "minimum": 1 } - }, - "description": "Both values must be powers of two; the bundled validator checks that constraint because JSON Schema cannot express it." - } - } -} diff --git a/plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json b/plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json deleted file mode 100644 index 4500f8d..0000000 --- a/plugins/oz-harness-support/skills/factory-files/schemas/scorer.schema.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "scorer.schema.json", - "$comment": "OFFLINE FLOOR - DO NOT TIGHTEN. This is the fallback copy that ships inside a Warp release, so it is routinely OLDER than the warp-server it validates against. additionalProperties is true and catalogue values are recorded as x-warp-known-values / x-warp-known-max-items annotations rather than enum, const, or maxItems, because a closed schema here rejects configuration a newer server accepts and pushes agents to delete working fields. Fetch GET /api/v1/factory-files/schemas/ for the exact schema; that one is served by the parser that owns the format and cannot lag it, so do not copy it over this file. When the format gains a value, add it to the annotation. The two deliberate exceptions are trigger filter keys, which only apply when both provider and event are known, and schemaVersion, which stops validation rather than misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md and references/validation.md.", - "title": "scorers//scorer.md frontmatter (v1alpha1)", - "description": "YAML frontmatter of a Scorer file. The Scorer name comes from its directory, and the Markdown body is its required rubric.", - "type": "object", - "required": ["agents", "labels", "passingScore", "model"], - "additionalProperties": true, - "properties": { - "description": { - "type": ["string", "null"] - }, - "agents": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "common.schema.json#/$defs/nonEmptyString" - }, - "description": "Names of Agents declared by this Factory." - }, - "enabled": { - "type": "boolean", - "description": "Defaults to true." - }, - "output": { - "$ref": "common.schema.json#/$defs/nonEmptyString", - "x-warp-known-values": ["classification"], - "description": "Scorer output form. classification is the current v1 form; newer forms are preserved for forward compatibility." - }, - "labels": { - "type": "array", - "minItems": 1, - "x-warp-known-max-items": 20, - "description": "Classifications this Scorer may return. The current server accepts at most 20; a larger set is left for the server to judge.", - "items": { - "type": "object", - "required": ["value", "score"], - "additionalProperties": true, - "properties": { - "value": { - "$ref": "common.schema.json#/$defs/nonEmptyString" - }, - "description": { - "type": ["string", "null"] - }, - "score": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - } - } - }, - "passingScore": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "samplingRate": { - "type": "number", - "minimum": 0, - "maximum": 100, - "description": "Percentage of eligible runs to score. Defaults to 25. Zero is invalid; use enabled: false." - }, - "model": { - "$ref": "common.schema.json#/$defs/nonEmptyString" - }, - "selfImprovement": { - "type": "boolean", - "description": "Defaults to false." - } - } -} diff --git a/plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py b/plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py old mode 100755 new mode 100644 index 63739c5..9b528fc --- a/plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py +++ b/plugins/oz-harness-support/skills/factory-files/scripts/validate_factory_files.py @@ -1,52 +1,35 @@ #!/usr/bin/env python3 -"""Validate a Factory file tree, against warp-server when it is reachable. +"""Validate a Factory file tree against warp-server. Usage: - python3 validate_factory_files.py [FACTORY_ROOT] [--json] [--offline] - [--server-root URL] [--schemas DIR] + python3 validate_factory_files.py [FACTORY_ROOT] [--json] [--server-root URL] FACTORY_ROOT defaults to the current directory and must contain factory.yaml. -The server owns the Factory file format, so this script asks it first: it reads -the tree's declared schemaVersion, confirms the server publishes that version, -and submits the tree to the validation endpoint. Only when that path is -unavailable does it fall back to the schemas and checks bundled here, and it -always says which of the two ran. A bundled result can be older than the -server; presenting one as though the server had agreed would be a lie. - -The script is intentionally dependency-free: it ships a restricted YAML reader -for the canonical forms this skill emits (no anchors, aliases, explicit tags, -or multiple documents) and a JSON Schema evaluator covering the keywords the -bundled schemas use. It is not a general-purpose YAML implementation. Anything -it cannot read confidently is reported rather than guessed at. - -Neither path resolves server state. Model IDs, environment IDs, secret names, -runner references, MCP server IDs, integration availability, and the values of -provider name aliases are all checked when the plan is applied. - -FORWARD COMPATIBILITY - DO NOT TIGHTEN --------------------------------------- -The bundled schemas ship inside a Warp release, so they are routinely older -than the warp-server they are used against. They therefore accept some input -the current server rejects, on purpose. Unknown properties, agent types, -credential strategies, harness types and per-harness capabilities, integration -slugs, trigger providers and events, runner platforms, Scorer output forms, and -the Scorer label cap are all deferred to the server. - -This tolerance is for the offline floor only. The server's own schemas are -exact for the version they describe, because they cannot lag the parser that -serves them. Do not copy them over these. - -If you are here because the offline path accepted something the server rejects, -the fix is usually a clearer server diagnostic, not a stricter schema. A false -rejection is far more expensive than a false acceptance: it blocks correct -work and invites an agent to "repair" valid configuration by deleting it, -whereas the server revalidates every tree at apply time anyway. - -Two checks are deliberately kept strict, and both are scoped so drift cannot -trip them: trigger filter keys apply only when the provider and event are both -recognized, and an unrecognized schemaVersion stops validation instead of -misapplying v1alpha1 rules. See warp-server specs/REMOTE-2868/TECH.md. +warp-server owns the Factory file format, so it is the only thing that decides +whether a tree is valid. This script collects the tree's resource files and +submits them to the validation endpoint, which runs the real parser plus the +state-independent checks the apply path would run next. + +There is deliberately no local fallback. A bundled copy of the format is +routinely older than the server it is used against, and a stale copy does not +degrade gracefully: it reports confident, wrong diagnostics that invite an +agent to "repair" valid configuration by deleting it. Reporting that a tree +could not be checked is strictly safer than reporting the wrong answer, so when +the server cannot be reached this script says so and validates nothing. + +That also means this script never parses YAML. It decides which files are +resource files from their paths alone and sends their bytes verbatim, so it +cannot disagree with the parser about what a document means. + +The endpoint does not resolve server state. Model IDs, environment IDs, secret +names, runner references, MCP server IDs, integration availability, and the +values of provider name aliases are all checked when the plan is applied. + +Exit codes: + 0 the server validated the tree and found no problem + 1 the server validated the tree and reported diagnostics + 2 the tree was not validated; the reason is printed """ from __future__ import annotations @@ -54,27 +37,43 @@ import argparse import json import os -import re import sys -import unicodedata import urllib.error import urllib.request from pathlib import Path from typing import Any, Optional -SCHEMA_BY_KIND = { - "factory": "factory.schema.json", - "agent": "agent.schema.json", - "automation": "automation.schema.json", - "runner": "runner.schema.json", - "scorer": "scorer.schema.json", -} +DEFAULT_SERVER_ROOT = "https://app.warp.dev" +VALIDATE_PATH = "/api/v1/factory-files/validate" -MAIN_AGENT_TYPES = {"MAIN", "FOREMAN"} +# Bounded so an unreachable or slow server reports quickly rather than stalling +# an authoring session. +REQUEST_TIMEOUT_SECONDS = 10.0 +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 + +# Mirrors the caps the endpoint enforces, so an oversized tree is reported here +# rather than collecting a 400. +MAX_REMOTE_FILES = 256 +MAX_REMOTE_FILE_BYTES = 256 * 1024 +MAX_REMOTE_CONTENT_BYTES = 2 * 1024 * 1024 + +SYMLINK_REFUSED = ( + "resource file is a symlink, or resolves outside the Factory root, and was not " + "read. The server parses the repository tree, so it sees the link itself rather " + "than its target and cannot accept this either. Replace it with a real file." +) + +EXIT_VALID = 0 +EXIT_DIAGNOSTICS = 1 +EXIT_NOT_VALIDATED = 2 + + +class NotValidated(Exception): + """The tree was not checked. This is never a pass.""" class Problem: - """One validation failure, located as precisely as the input allows.""" + """One reported failure, located as precisely as the server allows.""" def __init__(self, path: str, message: str, line: Optional[int] = None, pointer: str = ""): self.path = path @@ -100,695 +99,17 @@ def render(self) -> str: # --------------------------------------------------------------------------- -# Restricted YAML reader +# Selecting the tree to submit # --------------------------------------------------------------------------- -class YamlError(Exception): - def __init__(self, message: str, line: int): - super().__init__(message) - self.message = message - self.line = line - - -class _Line: - __slots__ = ("number", "indent", "content") - - def __init__(self, number: int, indent: int, content: str): - self.number = number - self.indent = indent - self.content = content - - -# Characters after which a quote opens a quoted scalar. Anywhere else a quote -# is an ordinary character, so "It's a thing" stays a plain scalar rather than -# an unterminated string. -_VALUE_START_CHARS = ":,[{-" - - -def _strip_comment(raw: str, line_number: int) -> str: - """Remove a trailing comment, honoring quoted scalars.""" - out: list[str] = [] - quote: Optional[str] = None - previous = "" - index = 0 - while index < len(raw): - char = raw[index] - if quote: - out.append(char) - if char == "\\" and quote == '"' and index + 1 < len(raw): - out.append(raw[index + 1]) - index += 2 - continue - if char == quote: - if quote == "'" and index + 1 < len(raw) and raw[index + 1] == "'": - out.append("'") - index += 2 - continue - quote = None - index += 1 - continue - if char in "\"'" and (previous == "" or previous in _VALUE_START_CHARS): - quote = char - out.append(char) - previous = char - index += 1 - continue - if char == "#" and (index == 0 or raw[index - 1] in " \t"): - break - out.append(char) - if char not in " \t": - previous = char - index += 1 - if quote: - raise YamlError("unterminated quoted string", line_number) - return "".join(out).rstrip() - - -def _reject_unsupported(content: str, line_number: int) -> None: - """Reject line-level constructs the Factory file parser does not accept. +def classify(relative: str) -> tuple[str, str]: + """Mirror the server's path classification. Returns (kind, name). - Anchors, aliases, and tags are checked in [_parse_scalar] instead, because - they are only meaningful where a node begins; scanning the whole line - rejects ordinary prose such as "A & B" or "see *this*". + This decides only which files are worth submitting. The server classifies + them again and owns the verdict, so a disagreement here costs a wasted + upload rather than a wrong answer. """ - if content.strip() in ("---", "..."): - raise YamlError("multiple YAML documents are not permitted", line_number) - if re.match(r"^\s*<<\s*:", content): - raise YamlError("yaml merge keys are not permitted", line_number) - - -def _opens_block_scalar(content: str, line_number: int) -> bool: - while content.startswith("- "): - content = content[2:].lstrip() - entry = _split_key(content, line_number) - value = entry[1] if entry is not None else content - return value[:1] in ("|", ">") - - -def _skip_block_scalar_body(raw_lines: list[str], index: int, header_indent: int) -> int: - """Return the index of the first line after a block scalar's body.""" - while index < len(raw_lines): - raw = raw_lines[index] - if raw.strip() == "": - index += 1 - continue - if len(raw) - len(raw.lstrip(" ")) <= header_indent: - break - index += 1 - return index - - -def _read_lines(text: str) -> list[_Line]: - raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") - lines: list[_Line] = [] - index = 0 - while index < len(raw_lines): - raw = raw_lines[index] - number = index + 1 - index += 1 - if "\t" in raw[: len(raw) - len(raw.lstrip(" \t"))]: - raise YamlError("tabs are not permitted for indentation", number) - content = _strip_comment(raw, number) - if not content.strip(): - continue - _reject_unsupported(content, number) - indent = len(content) - len(content.lstrip(" ")) - stripped = content.strip() - lines.append(_Line(number, indent, stripped)) - # A block scalar's body is opaque text. Leaving it out of the - # structural line list keeps its content from being read as YAML. - if _opens_block_scalar(stripped, number): - index = _skip_block_scalar_body(raw_lines, index, indent) - return lines - - -_INT_RE = re.compile(r"^[-+]?[0-9]+$") -_HEX_RE = re.compile(r"^[-+]?0x[0-9a-fA-F]+$") -_OCT_RE = re.compile(r"^[-+]?0o[0-7]+$") -_FLOAT_RE = re.compile(r"^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$") -_YAML_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[ \t]*(?:Z|[-+]\d{1,2}(?::\d{2})?))?)?$") - - -def _parse_scalar(token: str, line_number: int) -> Any: - token = token.strip() - if token == "" or token == "~" or token in ("null", "Null", "NULL"): - return None - if token.startswith("'"): - if not token.endswith("'") or len(token) < 2: - raise YamlError("invalid single-quoted string", line_number) - return token[1:-1].replace("''", "'") - if token.startswith('"'): - try: - return json.loads(token) - except json.JSONDecodeError as error: - raise YamlError(f"invalid double-quoted string: {error.msg}", line_number) from error - if token[0] in "&*": - raise YamlError("yaml anchors and aliases are not permitted", line_number) - if token[0] == "!": - raise YamlError("explicit yaml tags are not permitted", line_number) - if token in ("true", "True", "TRUE"): - return True - if token in ("false", "False", "FALSE"): - return False - if _INT_RE.match(token): - return int(token, 10) - if _HEX_RE.match(token): - return int(token, 16) - if _OCT_RE.match(token): - return int(token, 8) - if _FLOAT_RE.match(token): - return float(token) - if _YAML_DATE_RE.match(token): - raise YamlError("timestamps must be quoted so YAML keeps them as strings", line_number) - if token.lower() in {".inf", "+.inf", "-.inf", ".nan"}: - raise YamlError("non-finite YAML numbers are not permitted", line_number) - return token - - -def _split_key(content: str, line_number: int) -> Optional[tuple[str, str]]: - """Split `key: value`, honoring quoted keys. Returns None when absent.""" - quote: Optional[str] = None - index = 0 - while index < len(content): - char = content[index] - if quote: - if char == "\\" and quote == '"' and index + 1 < len(content): - index += 2 - continue - if char == quote: - if quote == "'" and index + 1 < len(content) and content[index + 1] == "'": - index += 2 - continue - quote = None - index += 1 - continue - if char in "\"'": - quote = char - index += 1 - continue - if char in "[{": - return None - if char == ":" and (index + 1 == len(content) or content[index + 1] in " \t"): - key_token = content[:index].strip() - key = _parse_scalar(key_token, line_number) - if not isinstance(key, str): - key = key_token - return key, content[index + 1 :].strip() - index += 1 - return None - - -def _parse_flow(text: str, line_number: int) -> Any: - value, rest = _parse_flow_value(text.strip(), line_number) - if rest.strip(): - raise YamlError("unexpected trailing content after flow collection", line_number) - return value - - -def _parse_flow_value(text: str, line_number: int) -> tuple[Any, str]: - text = text.lstrip() - if not text: - raise YamlError("unexpected end of flow collection", line_number) - if text[0] == "[": - items: list[Any] = [] - rest = text[1:].lstrip() - if rest.startswith("]"): - return items, rest[1:] - while True: - item, rest = _parse_flow_value(rest, line_number) - items.append(item) - rest = rest.lstrip() - if rest.startswith(","): - rest = rest[1:].lstrip() - if rest.startswith("]"): - return items, rest[1:] - continue - if rest.startswith("]"): - return items, rest[1:] - raise YamlError("unterminated flow sequence", line_number) - if text[0] == "{": - mapping: dict[str, Any] = {} - rest = text[1:].lstrip() - if rest.startswith("}"): - return mapping, rest[1:] - while True: - key_text, rest = _read_flow_scalar(rest, line_number) - rest = rest.lstrip() - if not rest.startswith(":"): - raise YamlError("flow mapping entry is missing ':'", line_number) - value, rest = _parse_flow_value(rest[1:], line_number) - key = _parse_scalar(key_text, line_number) - if not isinstance(key, str): - key = key_text.strip() - if key in mapping: - raise YamlError(f'duplicate key "{key}"', line_number) - mapping[key] = value - rest = rest.lstrip() - if rest.startswith(","): - rest = rest[1:].lstrip() - if rest.startswith("}"): - return mapping, rest[1:] - continue - if rest.startswith("}"): - return mapping, rest[1:] - raise YamlError("unterminated flow mapping", line_number) - token, rest = _read_flow_scalar(text, line_number) - return _parse_scalar(token, line_number), rest - - -def _read_flow_scalar(text: str, line_number: int) -> tuple[str, str]: - text = text.lstrip() - if text[:1] in ("'", '"'): - quote = text[0] - index = 1 - while index < len(text): - if text[index] == "\\" and quote == '"': - index += 2 - continue - if text[index] == quote: - if quote == "'" and text[index + 1 : index + 2] == "'": - index += 2 - continue - return text[: index + 1], text[index + 1 :] - index += 1 - raise YamlError("unterminated quoted string in flow collection", line_number) - index = 0 - while index < len(text) and text[index] not in ",]}:": - index += 1 - return text[:index].strip(), text[index:] - - -class _Reader: - def __init__(self, lines: list[_Line], raw_lines: list[str]): - self.lines = lines - self.raw_lines = raw_lines - self.index = 0 - - def peek(self) -> Optional[_Line]: - return self.lines[self.index] if self.index < len(self.lines) else None - - def parse_block(self, indent: int) -> Any: - line = self.peek() - if line is None or line.indent < indent: - return None - if line.content.startswith("- ") or line.content == "-": - return self._parse_sequence(line.indent) - return self._parse_mapping(line.indent) - - def _parse_sequence(self, indent: int) -> list[Any]: - items: list[Any] = [] - while True: - line = self.peek() - if line is None or line.indent != indent: - break - if not (line.content.startswith("- ") or line.content == "-"): - break - self.index += 1 - remainder = line.content[1:].strip() - if remainder == "": - items.append(self.parse_block(indent + 1)) - continue - entry = _split_key(remainder, line.number) - if entry is not None: - # An inline mapping entry opens a mapping whose remaining keys - # are indented to the column the first key started at. - after_dash = line.content[1:] - lead = len(after_dash) - len(after_dash.lstrip(" ")) - inline_indent = indent + 1 + lead - items.append(self._parse_inline_mapping(entry, line.number, inline_indent)) - continue - items.append(self._parse_value(remainder, line.number, indent)) - return items - - def _parse_inline_mapping( - self, entry: tuple[str, str], line_number: int, indent: int - ) -> dict[str, Any]: - key, value_token = entry - mapping: dict[str, Any] = {key: self._parse_value(value_token, line_number, indent)} - return self._parse_mapping(indent, existing=mapping) - - def _parse_mapping( - self, indent: int, existing: Optional[dict[str, Any]] = None - ) -> dict[str, Any]: - mapping: dict[str, Any] = existing if existing is not None else {} - while True: - line = self.peek() - if line is None or line.indent != indent: - break - if line.content.startswith("- "): - break - entry = _split_key(line.content, line.number) - if entry is None: - raise YamlError(f"expected 'key: value', found {line.content!r}", line.number) - self.index += 1 - key, value_token = entry - if key in mapping: - raise YamlError(f'duplicate key "{key}"', line.number) - mapping[key] = self._parse_value(value_token, line.number, indent) - return mapping - - def _parse_value(self, token: str, line_number: int, indent: int) -> Any: - if token.startswith("|") or token.startswith(">"): - return self._parse_block_scalar(token, line_number, indent) - if token.startswith("[") or token.startswith("{"): - return _parse_flow(token, line_number) - if token != "": - return _parse_scalar(token, line_number) - nested = self.peek() - if nested is None or nested.indent <= indent: - return None - return self.parse_block(nested.indent) - - def _parse_block_scalar(self, header: str, line_number: int, indent: int) -> str: - style = header[0] - chomp = "clip" - if "-" in header[1:]: - chomp = "strip" - elif "+" in header[1:]: - chomp = "keep" - collected: list[str] = [] - # line_number is 1-based, so it indexes the line after the header. - cursor = line_number - block_indent: Optional[int] = None - while cursor < len(self.raw_lines): - raw = self.raw_lines[cursor] - if raw.strip() == "": - collected.append("") - cursor += 1 - continue - current_indent = len(raw) - len(raw.lstrip(" ")) - if current_indent <= indent: - break - if block_indent is None: - block_indent = current_indent - collected.append(raw[block_indent:]) - cursor += 1 - if chomp != "keep": - while collected and collected[-1] == "": - collected.pop() - if style == "|": - text = "\n".join(collected) - else: - text = " ".join(part.strip() for part in collected if part.strip()) - if chomp == "strip" or not text: - return text - return text + "\n" - - -def load_yaml(text: str) -> Any: - """Parse the restricted YAML subset the Factory file format accepts.""" - raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") - lines = _read_lines(text) - if not lines: - return None - reader = _Reader(lines, raw_lines) - value = reader.parse_block(lines[0].indent) - remaining = reader.peek() - if remaining is not None: - raise YamlError(f"unexpected content {remaining.content!r}", remaining.number) - return value - - -def split_frontmatter(text: str) -> tuple[str, str, int]: - """Return a Markdown resource's frontmatter, body, and line offset.""" - normalized = text.replace("\r\n", "\n").replace("\r", "\n") - lines = normalized.split("\n") - if not lines or lines[0].rstrip() != "---": - raise YamlError("resource file must start with a frontmatter fence (---)", 1) - for index in range(1, len(lines)): - if lines[index].rstrip() == "---": - return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :]), 1 - raise YamlError("frontmatter is missing a closing fence (---)", 1) - - -# --------------------------------------------------------------------------- -# JSON Schema evaluator -# --------------------------------------------------------------------------- - -_TYPE_CHECKS = { - "object": lambda value: isinstance(value, dict), - "array": lambda value: isinstance(value, list), - "string": lambda value: isinstance(value, str), - "integer": lambda value: isinstance(value, int) and not isinstance(value, bool), - "number": lambda value: isinstance(value, (int, float)) and not isinstance(value, bool), - "boolean": lambda value: isinstance(value, bool), - "null": lambda value: value is None, -} - - -class SchemaStore: - """Loads sibling schema files and resolves local and relative $refs.""" - - def __init__(self, directory: Path): - self.directory = directory - self._cache: dict[str, Any] = {} - - def document(self, filename: str) -> Any: - if filename not in self._cache: - path = self.directory / filename - self._cache[filename] = json.loads(path.read_text(encoding="utf-8")) - return self._cache[filename] - - def resolve(self, ref: str, current: str) -> tuple[Any, str]: - filename, _, pointer = ref.partition("#") - target = filename or current - document = self.document(target) - node = document - for token in [segment for segment in pointer.split("/") if segment]: - token = token.replace("~1", "/").replace("~0", "~") - node = node[token] - return node, target - - -# Keywords the evaluator implements, and the annotations it may ignore. A -# keyword in neither set is reported rather than skipped: silently ignoring an -# unimplemented keyword would under-validate without any signal. -_SUPPORTED_KEYWORDS = frozenset( - { - "$ref", - "type", - "const", - "enum", - "minLength", - "maxLength", - "pattern", - "minimum", - "maximum", - "minItems", - "maxItems", - "uniqueItems", - "items", - "required", - "minProperties", - "properties", - "additionalProperties", - "propertyNames", - "allOf", - "anyOf", - "oneOf", - "not", - "if", - "then", - "else", - } -) -_ANNOTATION_KEYWORDS = frozenset( - { - "$schema", - "$id", - "$defs", - "$comment", - "title", - "description", - "x-warp-character-class", - "x-warp-known-max-items", - "x-warp-known-values", - "x-warp-max-trimmed-runes", - } -) - - - -def _matches_pattern(pattern: str, value: str) -> bool: - return re.search(pattern, value) is not None - - -def _describe(value: Any) -> str: - for name, check in _TYPE_CHECKS.items(): - if name != "number" and check(value): - return name - return type(value).__name__ - - -def validate_instance( - instance: Any, - schema: Any, - store: SchemaStore, - document: str, - pointer: str = "", -) -> list[str]: - """Evaluate the JSON Schema keywords used by the bundled schemas.""" - if schema is True or schema == {}: - return [] - if schema is False: - return [f"{pointer or '/'}: no value is allowed here"] - - errors: list[str] = [] - - unsupported = set(schema) - _SUPPORTED_KEYWORDS - _ANNOTATION_KEYWORDS - if unsupported: - listed = ", ".join(sorted(unsupported)) - errors.append( - f"{pointer or '/'}: this validator does not implement schema keyword(s) {listed}; " - "its result is incomplete until they are added" - ) - - if "$ref" in schema: - target, target_document = store.resolve(schema["$ref"], document) - errors.extend(validate_instance(instance, target, store, target_document, pointer)) - - if "type" in schema: - declared = schema["type"] - names = declared if isinstance(declared, list) else [declared] - if not any(_TYPE_CHECKS[name](instance) for name in names): - errors.append( - f"{pointer or '/'}: expected {' or '.join(names)}, found {_describe(instance)}" - ) - return errors - - if "const" in schema and instance != schema["const"]: - errors.append(f"{pointer or '/'}: must be {json.dumps(schema['const'])}") - if "enum" in schema and instance not in schema["enum"]: - allowed = ", ".join(json.dumps(option) for option in schema["enum"]) - errors.append(f"{pointer or '/'}: {json.dumps(instance)} must be one of {allowed}") - - if isinstance(instance, str): - if "minLength" in schema and len(instance) < schema["minLength"]: - required = schema["minLength"] - detail = "must not be empty" if required == 1 else f"must be at least {required} characters" - errors.append(f"{pointer or '/'}: {detail}") - if "maxLength" in schema and len(instance) > schema["maxLength"]: - errors.append(f"{pointer or '/'}: must be at most {schema['maxLength']} characters") - if "pattern" in schema and not _matches_pattern(schema["pattern"], instance): - errors.append(f"{pointer or '/'}: {json.dumps(instance)} does not match the required format") - - if isinstance(instance, (int, float)) and not isinstance(instance, bool): - if "minimum" in schema and instance < schema["minimum"]: - errors.append(f"{pointer or '/'}: must be at least {schema['minimum']}") - if "maximum" in schema and instance > schema["maximum"]: - errors.append(f"{pointer or '/'}: must be at most {schema['maximum']}") - - if isinstance(instance, list): - if "minItems" in schema and len(instance) < schema["minItems"]: - errors.append(f"{pointer or '/'}: must contain at least {schema['minItems']} entries") - if "maxItems" in schema and len(instance) > schema["maxItems"]: - errors.append(f"{pointer or '/'}: must contain at most {schema['maxItems']} entries") - if schema.get("uniqueItems") and _has_duplicates(instance): - errors.append(f"{pointer or '/'}: entries must be unique") - if "items" in schema: - for index, item in enumerate(instance): - errors.extend( - validate_instance(item, schema["items"], store, document, f"{pointer}/{index}") - ) - - if isinstance(instance, dict): - for name in schema.get("required", []): - if name not in instance: - errors.append(f"{pointer or '/'}: {name} is required") - if "minProperties" in schema and len(instance) < schema["minProperties"]: - errors.append(f"{pointer or '/'}: must declare at least {schema['minProperties']} field") - properties = schema.get("properties", {}) - for name, value in instance.items(): - if name in properties: - errors.extend( - validate_instance(value, properties[name], store, document, f"{pointer}/{name}") - ) - elif "additionalProperties" in schema: - additional = schema["additionalProperties"] - if additional is False: - known = ", ".join(sorted(properties)) or "none" - errors.append( - f"{pointer or '/'}: unknown field {json.dumps(name)} (accepted: {known})" - ) - else: - errors.extend( - validate_instance(value, additional, store, document, f"{pointer}/{name}") - ) - if "propertyNames" in schema: - for name in instance: - errors.extend( - validate_instance( - name, schema["propertyNames"], store, document, f"{pointer}/{name}" - ) - ) - - for subschema in schema.get("allOf", []): - errors.extend(validate_instance(instance, subschema, store, document, pointer)) - - if "anyOf" in schema: - branches = [ - validate_instance(instance, subschema, store, document, pointer) - for subschema in schema["anyOf"] - ] - if all(branch for branch in branches): - errors.append(_combine(pointer, schema, branches, "does not match any accepted form")) - - if "oneOf" in schema: - branches = [ - validate_instance(instance, subschema, store, document, pointer) - for subschema in schema["oneOf"] - ] - matched = [index for index, branch in enumerate(branches) if not branch] - if len(matched) == 0: - errors.append(_combine(pointer, schema, branches, "does not match any accepted form")) - elif len(matched) > 1: - errors.append( - f"{pointer or '/'}: matches more than one mutually exclusive form" - + (f" ({schema['description']})" if "description" in schema else "") - ) - - if "not" in schema and not validate_instance(instance, schema["not"], store, document, pointer): - errors.append( - f"{pointer or '/'}: " - + (schema.get("description") or "this form is not allowed here") - ) - - if "if" in schema: - matched = not validate_instance(instance, schema["if"], store, document, pointer) - branch = schema.get("then") if matched else schema.get("else") - if branch is not None: - errors.extend(validate_instance(instance, branch, store, document, pointer)) - - return errors - - -def _combine(pointer: str, schema: Any, branches: list[list[str]], summary: str) -> str: - detail = schema.get("description") - head = f"{pointer or '/'}: {detail or summary}" - nested = sorted({message for branch in branches for message in branch}) - if not nested: - return head - return head + " — " + "; ".join(nested[:4]) - - -def _has_duplicates(items: list[Any]) -> bool: - seen: list[str] = [] - for item in items: - key = json.dumps(item, sort_keys=True) - if key in seen: - return True - seen.append(key) - return False - - -# --------------------------------------------------------------------------- -# Factory tree traversal -# --------------------------------------------------------------------------- - - -def classify(relative: str) -> tuple[str, str]: - """Mirror the server's path classification. Returns (kind, name).""" if relative == "factory.yaml": return "factory", "" segments = relative.split("/") @@ -825,6 +146,7 @@ def classify(relative: str) -> tuple[str, str]: def _valid_name(name: str) -> bool: return name not in ("", ".", "..") and "/" not in name + def _resource_files(root: Path) -> list[Path]: files = [root / "factory.yaml"] for directory_name in ("agents", "automations", "runners", "scorers"): @@ -847,8 +169,7 @@ def _leaves_factory_root(path: Path, root: Path) -> bool: symlink is a blob whose content is the target path, so it sees the link itself. Following one here would both diverge from that and read a file the Factory does not contain - an untrusted repository could otherwise point a - resource at any readable path and have its content echoed back in a parse - error. + resource at any readable path and have its content uploaded. """ if path.is_symlink(): return True @@ -859,554 +180,64 @@ def _leaves_factory_root(path: Path, root: Path) -> bool: return False -SUPPORTED_SCHEMA_VERSION = "v1alpha1" - -SYMLINK_REFUSED = ( - "resource file is a symlink, or resolves outside the Factory root, and was not " - "read. The server parses the repository tree, so it sees the link itself rather " - "than its target and cannot accept this either. Replace it with a real file." -) - - -def validate_tree(root: Path, store: SchemaStore) -> list[Problem]: - problems: list[Problem] = [] - documents: dict[str, tuple[str, str, Any]] = {} - seen_names: dict[tuple[str, str], str] = {} - +def collect_tree(root: Path) -> tuple[list[dict[str, str]], list[Problem]]: + """Collect the resource files to submit, refusing symlinks as the server does.""" if not (root / "factory.yaml").is_file(): - return [Problem("factory.yaml", "factory.yaml is required at the Factory root")] - + raise NotValidated(f"{root} has no factory.yaml, so it is not a Factory root") + files: list[dict[str, str]] = [] + problems: list[Problem] = [] + total = 0 for absolute in _resource_files(root): relative = absolute.relative_to(root).as_posix() - kind, name = classify(relative) - if kind in ("unrelated", "skill"): - continue - if kind == "invalid": - problems.append( - Problem( - relative, - "resource files must use factory.yaml, agents//agent.md, " - "automations//automation.md, runners/.yaml, " - "or scorers//scorer.md", - ) - ) + kind, _ = classify(relative) + if kind in ("unrelated", "skill", "invalid"): continue - if kind in ("automation", "runner", "scorer"): - previous = seen_names.get((kind, name)) - if previous is not None: - problems.append( - Problem(relative, f'{kind} "{name}" is also declared by {previous}') - ) - continue - seen_names[(kind, name)] = relative - if _leaves_factory_root(absolute, root): problems.append(Problem(relative, SYMLINK_REFUSED)) continue - try: - text = absolute.read_text(encoding="utf-8") + content = absolute.read_text(encoding="utf-8") except (OSError, UnicodeError) as error: problems.append(Problem(relative, f"could not read UTF-8 resource: {error}")) continue - offset = 0 - body = "" - try: - if kind in ("agent", "automation", "scorer"): - frontmatter, body, offset = split_frontmatter(text) - parsed = load_yaml(frontmatter) if frontmatter.strip() else {} - else: - parsed = load_yaml(text) - except YamlError as error: - problems.append(Problem(relative, error.message, error.line + offset)) - continue - - if parsed is None: - parsed = {} - if not isinstance(parsed, dict): - problems.append(Problem(relative, "document root must be a YAML mapping")) - continue - - documents[relative] = (kind, name, parsed) - schema = store.document(SCHEMA_BY_KIND[kind]) - for message in validate_instance(parsed, schema, store, SCHEMA_BY_KIND[kind]): - pointer, _, detail = message.partition(": ") - problems.append(Problem(relative, detail, pointer=pointer.lstrip("/").replace("/", "."))) - if kind == "automation": - problems.extend(_automation_semantics(relative, parsed)) - elif kind == "factory": - problems.extend(_factory_semantics(relative, parsed)) - elif kind == "runner": - problems.extend(_runner_semantics(relative, parsed)) - elif kind == "scorer": - problems.extend(_scorer_semantics(relative, parsed, body)) - - problems.extend(_validate_cross_file(documents)) - return problems - - -def _scorer_semantics(relative: str, parsed: dict[str, Any], body: str) -> list[Problem]: - problems: list[Problem] = [] - if not body.strip(): - problems.append(Problem(relative, "the Markdown body is the rubric and must not be empty")) - - agents = parsed.get("agents") - if isinstance(agents, list): - normalized_agents = [value.strip() for value in agents if isinstance(value, str)] - if len(set(normalized_agents)) != len(normalized_agents): - problems.append(Problem(relative, "agent names must be unique after trimming", pointer="agents")) - - labels = parsed.get("labels") - threshold = parsed.get("passingScore") - numeric_scores: list[float] = [] - if isinstance(labels, list): - seen_labels: set[str] = set() - for index, label in enumerate(labels): - if not isinstance(label, dict): - continue - value = label.get("value") - if isinstance(value, str): - normalized_value = value.strip() - if normalized_value in seen_labels: - problems.append( - Problem( - relative, - f'duplicate label "{normalized_value}"', - pointer=f"labels.{index}.value", - ) - ) - seen_labels.add(normalized_value) - score = label.get("score") - if isinstance(score, (int, float)) and not isinstance(score, bool): - numeric_scores.append(float(score)) - if ( - isinstance(threshold, (int, float)) - and not isinstance(threshold, bool) - and numeric_scores - ): - threshold_value = float(threshold) - if not any(score >= threshold_value for score in numeric_scores): - problems.append( - Problem( - relative, - "at least one label score must be at or above passingScore", - pointer="passingScore", - ) - ) - if not any(score < threshold_value for score in numeric_scores): - problems.append( - Problem( - relative, - "at least one label score must be below passingScore", - pointer="passingScore", - ) - ) - - sampling_rate = parsed.get("samplingRate") - if isinstance(sampling_rate, (int, float)) and not isinstance(sampling_rate, bool): - if float(sampling_rate) == 0: - problems.append( - Problem( - relative, - "samplingRate must not be 0; use enabled: false to stop scoring", - pointer="samplingRate", - ) - ) - return problems - - -def _factory_semantics(relative: str, parsed: dict[str, Any]) -> list[Problem]: - problems: list[Problem] = [] - alias = parsed.get("alias") - if isinstance(alias, str): - normalized_alias = alias.strip() - if len(normalized_alias) > 60: - problems.append(Problem(relative, "alias must not exceed 60 characters", pointer="alias")) - if any( - unicodedata.category(character)[:1] not in {"L", "N"} and character not in " _.-" - for character in normalized_alias - ): - problems.append( - Problem( - relative, - "alias may only contain letters, digits, spaces, '-', '_', and '.'", - pointer="alias", - ) - ) - - secrets = parsed.get("secrets") - if isinstance(secrets, list): - normalized = [value.strip() for value in secrets if isinstance(value, str)] - if len(set(normalized)) != len(normalized): - problems.append( - Problem(relative, "secret names must be unique after trimming", pointer="secrets") - ) - - repositories = parsed.get("repositories") - if isinstance(repositories, list): - seen: set[tuple[str, str]] = set() - for index, repository in enumerate(repositories): - if not isinstance(repository, dict): - continue - owner, name = repository.get("owner"), repository.get("name") - if not isinstance(owner, str) or not isinstance(name, str): - continue - key = (owner.strip(), name.strip()) - if key in seen: - problems.append( - Problem( - relative, - f"duplicate repository {key[0]}/{key[1]} after trimming", - pointer=f"repositories.{index}", - ) - ) - seen.add(key) - return problems - - -def _runner_semantics(relative: str, parsed: dict[str, Any]) -> list[Problem]: - shape = parsed.get("instanceShape") - platform = parsed.get("platform") - os_name = platform.get("os", "linux") if isinstance(platform, dict) else "linux" - if os_name != "linux" or not isinstance(shape, dict): - return [] - problems: list[Problem] = [] - for field in ("vcpus", "memoryGb"): - value = shape.get(field) - if isinstance(value, int) and not isinstance(value, bool) and value > 0: - if value & (value - 1): - problems.append( - Problem( - relative, - f"{field} must be a power of two for Linux runners", - pointer=f"instanceShape.{field}", - ) - ) - return problems - - -_CRON_DESCRIPTORS = { - "@yearly", - "@annually", - "@monthly", - "@weekly", - "@daily", - "@midnight", - "@hourly", -} -_DURATION_RE = re.compile( - r"^[+-]?(?:0|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:ns|us|µs|μs|ms|s|m|h))+)$" -) -_MONTH_NAMES = { - "jan": 1, - "feb": 2, - "mar": 3, - "apr": 4, - "may": 5, - "jun": 6, - "jul": 7, - "aug": 8, - "sep": 9, - "oct": 10, - "nov": 11, - "dec": 12, -} -_DAY_NAMES = {"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6} - - -def _cron_number(value: str, names: Optional[dict[str, int]]) -> Optional[int]: - if names is not None and value.lower() in names: - return names[value.lower()] - if not re.fullmatch(r"\d+", value): - return None - return int(value) - - -def _valid_cron_field( - field: str, minimum: int, maximum: int, names: Optional[dict[str, int]] = None -) -> bool: - for expression in filter(None, field.split(",")): - parts = expression.split("/") - if len(parts) > 2: - return False - base = parts[0] - if len(parts) == 2 and (not parts[1].isdigit() or int(parts[1]) == 0): - return False - if base in {"*", "?"}: - continue - bounds = base.split("-") - if len(bounds) > 2: - return False - start = _cron_number(bounds[0], names) - end = _cron_number(bounds[-1], names) - if start is None or end is None: - return False - if start < minimum or end > maximum or start > end: - return False - return bool(field) - - -def _valid_cron(expression: str) -> bool: - expression = expression.strip() - if expression in _CRON_DESCRIPTORS: - return True - if expression.startswith("@every "): - return _DURATION_RE.fullmatch(expression[len("@every ") :]) is not None - fields = expression.split() - if len(fields) != 5: - return False - return all( - validator - for validator in ( - _valid_cron_field(fields[0], 0, 59), - _valid_cron_field(fields[1], 0, 23), - _valid_cron_field(fields[2], 1, 31), - _valid_cron_field(fields[3], 1, 12, _MONTH_NAMES), - _valid_cron_field(fields[4], 0, 6, _DAY_NAMES), - ) - ) - - -def _automation_semantics(relative: str, parsed: dict[str, Any]) -> list[Problem]: - """Report filter values listed in both in and not_in. - - Such a filter can never match, so the server rejects it rather than - persisting a silently dead subscription. JSON Schema cannot compare two - sibling arrays, so the check lives here. - """ - problems: list[Problem] = [] - triggers = parsed.get("triggers") - if not isinstance(triggers, list): - return problems - schedule_keys: set[str] = set() - for index, trigger in enumerate(triggers): - if not isinstance(trigger, dict): - continue - schedule = trigger.get("schedule") - if isinstance(schedule, dict): - name = schedule.get("name") - normalized_name = name.strip() if isinstance(name, str) else "" - key = f"name:{normalized_name}" if normalized_name else "unnamed" - if key in schedule_keys: - detail = ( - f'duplicate inline schedule name "{normalized_name}"' - if normalized_name - else "at most one inline schedule may omit name" - ) - problems.append( - Problem(relative, detail, pointer=f"triggers.{index}.schedule") - ) - schedule_keys.add(key) - cron = schedule.get("cron") - if isinstance(cron, str) and not _valid_cron(cron): - problems.append( - Problem( - relative, - f"invalid cron expression {json.dumps(cron)}", - pointer=f"triggers.{index}.schedule.cron", - ) - ) - - declared = trigger.get("filter") - if not isinstance(declared, dict): - continue - provider, event = trigger.get("provider"), trigger.get("event") - for field, matcher in declared.items(): - if not isinstance(matcher, dict): - continue - included = matcher.get("in") - excluded = matcher.get("not_in") - if not isinstance(included, list) or not isinstance(excluded, list): - continue - normalize = _matcher_normalizer(provider, event, field) - excluded_keys = {normalize(value) for value in excluded} - for value in included: - if normalize(value) in excluded_keys: - problems.append( - Problem( - relative, - f"{json.dumps(value)} is present in, or equivalent to a value in, " - "both in and not_in, " - "so this filter can never match", - pointer=f"triggers.{index}.filter.{field}", - ) - ) - return problems - - -def _matcher_normalizer(provider: Any, event: Any, field: str): - lowercase_fields: set[tuple[str, str]] = { - ("github", "assignees"), - ("github", "authors"), - ("github", "mentioned"), - ("github", "reviewers"), - ("github", "reviewer_teams"), - ("github", "review_states"), - ("github", "conclusions"), - ("github", "workflows"), - ("gitlab", "repos"), - ("gitlab", "actions"), - ("gitlab", "mentioned"), - ("linear", "mentioned_user_ids"), - ("linear", "labels"), - } - - def normalize(value: Any) -> Any: - if not isinstance(value, str): - return value - if provider == "github" and event == "push" and field == "branches": - return value[len("refs/heads/") :] if value.startswith("refs/heads/") else value - if provider == "slack" and field == "emojis": - emoji = value.strip().strip(":") - skin_tone = emoji.find("::skin-tone-") - if skin_tone >= 0: - emoji = emoji[:skin_tone] - return emoji.lower() - if field == "keywords" and provider in {"linear", "slack", "jira"}: - return value.strip().lower() - if (provider, field) in lowercase_fields: - return value.lower() - return value - - return normalize - - -def _validate_cross_file(documents: dict[str, tuple[str, str, Any]]) -> list[Problem]: - """Check the tree-level rules that no single-document schema can express. - - Runner references are deliberately not checked: a name the tree does not - declare legitimately resolves to an existing team runner on the server. - """ - problems: list[Problem] = [] - agent_names: set[str] = set() - main_agents: list[str] = [] - - for relative, (kind, name, parsed) in documents.items(): - if kind == "agent": - agent_names.add(name) - if str(parsed.get("agentType", "")) in MAIN_AGENT_TYPES: - main_agents.append(relative) - - if not main_agents: - problems.append( - Problem("factory.yaml", "exactly one Agent must declare agentType MAIN or FOREMAN") - ) - elif len(main_agents) > 1: - for relative in sorted(main_agents): - problems.append( - Problem(relative, "only one Agent may declare agentType MAIN or FOREMAN") - ) - - for relative, (kind, _, parsed) in documents.items(): - if kind == "automation": - agent = parsed.get("agent") - if isinstance(agent, str) and agent not in agent_names: - problems.append( - Problem(relative, f'agent "{agent}" must name a declared Agent', pointer="agent") - ) - elif kind == "scorer": - agents = parsed.get("agents") - if not isinstance(agents, list): - continue - for index, agent in enumerate(agents): - if isinstance(agent, str) and agent.strip() not in agent_names: - problems.append( - Problem( - relative, - f'agent "{agent.strip()}" must name a declared Agent', - pointer=f"agents.{index}", - ) - ) - return problems + encoded = len(content.encode("utf-8")) + if encoded > MAX_REMOTE_FILE_BYTES: + raise NotValidated(f"{relative} is larger than the endpoint accepts") + total += encoded + if total > MAX_REMOTE_CONTENT_BYTES or len(files) >= MAX_REMOTE_FILES: + raise NotValidated("the tree is larger than the endpoint accepts") + files.append({"path": relative, "content": content}) + if not files: + raise NotValidated("the tree has no resource files to submit") + return files, problems # --------------------------------------------------------------------------- # Server-backed validation # --------------------------------------------------------------------------- -DEFAULT_SERVER_ROOT = "https://app.warp.dev" -SCHEMA_REGISTRY_PATH = "/api/v1/factory-files/schemas" -VALIDATE_PATH = "/api/v1/factory-files/validate" - -# Bounded so an unreachable or slow server degrades to the offline floor in -# seconds rather than stalling an authoring session. -REQUEST_TIMEOUT_SECONDS = 10.0 -MAX_RESPONSE_BYTES = 8 * 1024 * 1024 - -# Mirrors the caps the endpoint enforces, so an oversized tree falls back -# locally instead of collecting a 400 from the server. -MAX_REMOTE_FILES = 256 -MAX_REMOTE_FILE_BYTES = 256 * 1024 -MAX_REMOTE_CONTENT_BYTES = 2 * 1024 * 1024 - - -class RemoteUnavailable(Exception): - """The server could not be used, so the offline floor has to run.""" - - -class UnsupportedRemoteVersion(Exception): - """The server does not publish the version this tree declares. - - This is not a fallback case. Measuring a version nobody recognizes against - v1alpha1 rules would bury the one useful fact under invented unknown-field - reports. - """ - class Outcome: - """What ran, what it found, and what it deliberately did not check.""" + """What the server found, and what it deliberately did not check.""" def __init__( self, - mode: str, schema_version: str, problems: list[Problem], deferred: Optional[list[dict[str, Any]]] = None, - fallback_reason: str = "", ): - self.mode = mode self.schema_version = schema_version self.problems = problems self.deferred = deferred or [] - self.fallback_reason = fallback_reason def disclosure(self) -> str: """The sentence the agent must repeat. Never claim more than ran.""" - if self.mode == "remote": - return ( - f"Validated with the warp-server parser for {self.schema_version}; " - "state-dependent apply checks were not run." - ) - if self.mode == "unsupported-version": - return ( - f"This tree declares {self.schema_version}, which was not validated; " - "check it against a server that publishes that version rather than " - "downgrading schemaVersion." - ) return ( - f"Server validation was unavailable ({self.fallback_reason}); validated with " - f"the bundled offline {self.schema_version} fallback, which may be older than " - "the server. State-dependent apply checks were not run." + f"Validated with the warp-server parser for {self.schema_version}; " + "state-dependent apply checks were not run." ) -def declared_schema_version(root: Path) -> str: - """Return the version factory.yaml declares, defaulting as the parser does.""" - factory_file = root / "factory.yaml" - if _leaves_factory_root(factory_file, root): - return SUPPORTED_SCHEMA_VERSION - try: - parsed = load_yaml(factory_file.read_text(encoding="utf-8")) - except (OSError, UnicodeError, YamlError): - return SUPPORTED_SCHEMA_VERSION - if not isinstance(parsed, dict): - return SUPPORTED_SCHEMA_VERSION - declared = parsed.get("schemaVersion") - if not isinstance(declared, str) or not declared.strip(): - return SUPPORTED_SCHEMA_VERSION - return declared.strip() - - def server_root(argument: Optional[str]) -> str: """Resolve the server to ask, so a local or staging root needs no code change.""" chosen = argument or os.environ.get("WARP_SERVER_ROOT") or DEFAULT_SERVER_ROOT @@ -1414,7 +245,7 @@ def server_root(argument: Optional[str]) -> str: def _request_json(url: str, token: Optional[str] = None, payload: Optional[Any] = None) -> Any: - """Fetch or post JSON, turning every failure class into RemoteUnavailable.""" + """Post or fetch JSON, turning every failure class into NotValidated.""" data = None headers = {"Accept": "application/json"} if payload is not None: @@ -1427,75 +258,38 @@ def _request_json(url: str, token: Optional[str] = None, payload: Optional[Any] with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: body = response.read(MAX_RESPONSE_BYTES + 1) except urllib.error.HTTPError as error: - raise RemoteUnavailable(f"the server answered HTTP {error.code}") from error + raise NotValidated(f"the server answered HTTP {error.code}") from error except Exception as error: # DNS, TLS, connection, timeout, proxy, ... - raise RemoteUnavailable(f"the server could not be reached: {error}") from error + raise NotValidated(f"the server could not be reached: {error}") from error if len(body) > MAX_RESPONSE_BYTES: - raise RemoteUnavailable("the server response was implausibly large") + raise NotValidated("the server response was implausibly large") try: return json.loads(body.decode("utf-8")) except (UnicodeError, ValueError) as error: - raise RemoteUnavailable(f"the server response was not JSON: {error}") from error + raise NotValidated(f"the server response was not JSON: {error}") from error -def _remote_payload(root: Path) -> tuple[list[dict[str, str]], list[Problem]]: - """Collect the tree to submit, refusing symlinks the way the server does.""" - files: list[dict[str, str]] = [] - problems: list[Problem] = [] - total = 0 - for absolute in _resource_files(root): - relative = absolute.relative_to(root).as_posix() - kind, _ = classify(relative) - if kind in ("unrelated", "skill", "invalid"): - continue - if _leaves_factory_root(absolute, root): - problems.append(Problem(relative, SYMLINK_REFUSED)) - continue - try: - content = absolute.read_text(encoding="utf-8") - except (OSError, UnicodeError) as error: - problems.append(Problem(relative, f"could not read UTF-8 resource: {error}")) - continue - encoded = len(content.encode("utf-8")) - if encoded > MAX_REMOTE_FILE_BYTES: - raise RemoteUnavailable(f"{relative} is larger than the endpoint accepts") - total += encoded - if total > MAX_REMOTE_CONTENT_BYTES or len(files) >= MAX_REMOTE_FILES: - raise RemoteUnavailable("the tree is larger than the endpoint accepts") - files.append({"path": relative, "content": content}) - if not files: - raise RemoteUnavailable("the tree has no resource files to submit") - return files, problems - +def validate(root: Path, base_url: str) -> Outcome: + """Submit the tree to the server, or raise NotValidated. -def validate_with_server(root: Path, base_url: str, version: str) -> Outcome: - """Validate through the server, or raise so the caller falls back. + The endpoint reads the declared schemaVersion itself and reports an + unrecognized one as a diagnostic, so there is nothing to pre-flight and no + reason for this script to read the tree's YAML. - Fetching the schema is not validation: a reachable registry with an - unusable validate endpoint still means the tree was never checked. + The endpoint needs no credential. WARP_API_KEY is forwarded when the + environment already carries one, as an Oz sandbox does, so the request is + attributable there; nothing requires it, because a local authoring agent + runs in a shell that cannot see the Warp client's session. """ token = os.environ.get("WARP_API_KEY") - if not token: - raise RemoteUnavailable("WARP_API_KEY is not set and the endpoint is authenticated") - - registry = _request_json(base_url + SCHEMA_REGISTRY_PATH) - if not isinstance(registry, dict) or not isinstance(registry.get("versions"), list): - raise RemoteUnavailable("the schema registry response was malformed") - published = { - entry.get("schema_version") for entry in registry["versions"] if isinstance(entry, dict) - } - if version not in published: - raise UnsupportedRemoteVersion(version) - - files, local_problems = _remote_payload(root) + files, problems = collect_tree(root) response = _request_json(base_url + VALIDATE_PATH, token=token, payload={"files": files}) if not isinstance(response, dict) or not isinstance(response.get("diagnostics"), list): - raise RemoteUnavailable("the validation response was malformed") + raise NotValidated("the validation response was malformed") - problems = list(local_problems) for diagnostic in response["diagnostics"]: if not isinstance(diagnostic, dict): - raise RemoteUnavailable("the validation response was malformed") + raise NotValidated("the validation response was malformed") code = str(diagnostic.get("code", "")) message = str(diagnostic.get("message", "")) line = diagnostic.get("line") @@ -1511,88 +305,46 @@ def validate_with_server(root: Path, base_url: str, version: str) -> Outcome: ] reported = response.get("schema_version") return Outcome( - "remote", - reported if isinstance(reported, str) and reported else version, + reported if isinstance(reported, str) and reported else "unknown", problems, deferred, ) -def validate(root: Path, store: SchemaStore, base_url: str, offline: bool) -> Outcome: - """Validate against the server when it is usable, otherwise offline.""" - version = declared_schema_version(root) - fallback_reason = "--offline was requested" - if not offline: - try: - return validate_with_server(root, base_url, version) - except UnsupportedRemoteVersion: - return Outcome( - "unsupported-version", - version, - [ - Problem( - "factory.yaml", - f"the server does not publish schema version {version!r}, so this " - "tree was not validated; correct the version rather than " - "downgrading it to make a check pass", - pointer="schemaVersion", - ) - ], - ) - except RemoteUnavailable as error: - fallback_reason = str(error) - - if version != SUPPORTED_SCHEMA_VERSION: - # The offline floor may only judge the one version it describes. - return Outcome( - "unsupported-version", - version, - [ - Problem( - "factory.yaml", - f"these bundled schemas describe {SUPPORTED_SCHEMA_VERSION}, not " - f"{version!r}, so this tree was not validated locally; check it with " - "the server instead of downgrading schemaVersion", - pointer="schemaVersion", - ) - ], - ) - return Outcome("offline", version, validate_tree(root, store), fallback_reason=fallback_reason) - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("root", nargs="?", default=".", help="Factory root containing factory.yaml") parser.add_argument("--json", action="store_true", help="emit machine-readable output") - parser.add_argument( - "--offline", - action="store_true", - help="skip the server and use the bundled schemas, which may be older", - ) parser.add_argument( "--server-root", default=None, help="warp-server root to validate against; defaults to $WARP_SERVER_ROOT then " + DEFAULT_SERVER_ROOT, ) - parser.add_argument( - "--schemas", - default=str(Path(__file__).resolve().parent.parent / "schemas"), - help="directory holding the bundled JSON Schemas", - ) args = parser.parse_args() root = Path(args.root).resolve() - store = SchemaStore(Path(args.schemas).resolve()) - outcome = validate(root, store, server_root(args.server_root), args.offline) - problems = outcome.problems + try: + outcome = validate(root, server_root(args.server_root)) + except NotValidated as reason: + report = ( + f"This tree was NOT validated: {reason}. Nothing here says the files are " + "correct or incorrect. Validate against a reachable warp-server, and say " + "plainly that validation did not run." + ) + if args.json: + print(json.dumps({"validated": False, "reason": str(reason)}, indent=2)) + else: + print(report, file=sys.stderr) + return EXIT_NOT_VALIDATED + problems = outcome.problems if args.json: print( json.dumps( { + "validated": True, "valid": not problems, - "validated_with": outcome.mode, "schema_version": outcome.schema_version, "disclosure": outcome.disclosure(), "problems": [problem.as_dict() for problem in problems], @@ -1601,14 +353,14 @@ def main() -> int: indent=2, ) ) - return 1 if problems else 0 + return EXIT_DIAGNOSTICS if problems else EXIT_VALID if problems: print(f"{len(problems)} problem(s) in {root}:", file=sys.stderr) for problem in problems: print(f" {problem.render()}", file=sys.stderr) print(outcome.disclosure(), file=sys.stderr) - return 1 + return EXIT_DIAGNOSTICS print(f"{root}: factory files are valid.") print(outcome.disclosure()) @@ -1617,7 +369,7 @@ def main() -> int: f" deferred: {entry.get('path', '')} {entry.get('field', '')} " f"({entry.get('kind', '')}) is resolved when the plan is applied" ) - return 0 + return EXIT_VALID if __name__ == "__main__": diff --git a/plugins/oz-harness-support/tests/test-factory-files.sh b/plugins/oz-harness-support/tests/test-factory-files.sh index a3acac8..d5ffd06 100755 --- a/plugins/oz-harness-support/tests/test-factory-files.sh +++ b/plugins/oz-harness-support/tests/test-factory-files.sh @@ -4,13 +4,15 @@ # # The skill tree under skills/factory-files is a byte-for-byte copy of # resources/bundled/skills/factory-files in warpdotdev/warp at the commit named -# in this plugin's README. Its own regression corpus lives there; this checks -# the one thing the copy can get wrong on its way over, which is arriving -# incomplete or unrunnable. +# in this plugin's README. Correctness of the Factory file format is owned by +# warp-server, and the skill's own behavioural corpus lives in warp; this +# checks the two things a copy can get wrong on its way over: arriving +# incomplete, and arriving with a local copy of the format that should not +# exist. # -# The validator prefers warp-server and falls back to the bundled schemas, so -# every run here passes --offline: CI has no server and no API key, and the -# offline floor is what a mirror needs to guarantee. +# There is no offline mode to exercise. CI has no warp-server, so the runnable +# assertion here is that the validator reports a tree as NOT validated rather +# than guessing at a verdict. set -uo pipefail @@ -37,12 +39,6 @@ echo "factory-files mirror" for required in \ "$SKILL/SKILL.md" \ "$VALIDATOR" \ - "$SKILL/schemas/common.schema.json" \ - "$SKILL/schemas/factory.schema.json" \ - "$SKILL/schemas/agent.schema.json" \ - "$SKILL/schemas/automation.schema.json" \ - "$SKILL/schemas/runner.schema.json" \ - "$SKILL/schemas/scorer.schema.json" \ "$SKILL/references/examples.md" \ "$SKILL/references/scorers.md" \ "$SKILL/references/validation.md"; do @@ -53,17 +49,32 @@ for required in \ fi done +# A bundled copy of the format is the failure this design removed: it ships +# inside a release, goes stale against the server, and then reports valid +# fields as unknown. +if find "$SKILL" -name '*.schema.json' | grep -q .; then + fail "the mirror carries bundled schemas, which go stale against the server" +else + pass "no local copy of the format" +fi + if ! command -v python3 >/dev/null 2>&1; then - fail "python3 is required to run the bundled validator" + fail "python3 is required to run the validator" echo " $PASSED passed, $FAILED failed" exit 1 fi +if python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$VALIDATOR" 2>/dev/null; then + pass "the validator parses" +else + fail "the validator does not parse" +fi + WORKSPACE="$(mktemp -d)" trap 'rm -rf "$WORKSPACE"' EXIT -mkdir -p "$WORKSPACE/valid/agents/main" "$WORKSPACE/invalid/agents/main" -cat >"$WORKSPACE/valid/factory.yaml" <<'YAML' +mkdir -p "$WORKSPACE/tree/agents/main" +cat >"$WORKSPACE/tree/factory.yaml" <<'YAML' schemaVersion: v1alpha1 name: mirror-smoke repositories: @@ -72,34 +83,35 @@ repositories: agentDefaults: model: auto YAML -printf -- '---\nagentType: MAIN\n---\nDo the thing.\n' >"$WORKSPACE/valid/agents/main/agent.md" - -# The same tree with no MAIN agent, which the tree-level rules must reject. -cp "$WORKSPACE/valid/factory.yaml" "$WORKSPACE/invalid/factory.yaml" -printf -- '---\ndescription: no main agent\n---\nDo the thing.\n' >"$WORKSPACE/invalid/agents/main/agent.md" +printf -- '---\nagentType: MAIN\n---\nDo the thing.\n' >"$WORKSPACE/tree/agents/main/agent.md" -output="$(python3 "$VALIDATOR" "$WORKSPACE/valid" --offline 2>&1)" -if [ $? -eq 0 ]; then - pass "a valid tree is accepted offline" +# Port 9 (discard) is reliably closed, so this exercises the unreachable-server +# path without depending on the network. +output="$(python3 "$VALIDATOR" "$WORKSPACE/tree" --server-root http://127.0.0.1:9 2>&1)" +status=$? +if [ "$status" -eq 2 ]; then + pass "an unreachable server exits 2" else - fail "a valid tree was rejected offline" "$output" + fail "an unreachable server should exit 2, got $status" "$output" fi case "$output" in - *"Server validation was unavailable"*) - pass "the offline fallback is disclosed" + *"was NOT validated"*) + pass "an unreachable server is reported as not validated" ;; *) - fail "the offline fallback was not disclosed" "$output" + fail "the missing verdict was not reported" "$output" ;; esac -output="$(python3 "$VALIDATOR" "$WORKSPACE/invalid" --offline 2>&1)" -if [ $? -ne 0 ]; then - pass "a tree with no MAIN agent is rejected offline" -else - fail "a tree with no MAIN agent was accepted offline" "$output" -fi +case "$output" in + *"Validated with the warp-server parser"*) + fail "a run with no server claimed a server verdict" "$output" + ;; + *) + pass "no verdict is claimed without a server" + ;; +esac echo " $PASSED passed, $FAILED failed" [ "$FAILED" -eq 0 ] || exit 1