Skip to content

Commit bcd23de

Browse files
committed
feat: validate command with five fail-fast schema/lockstep/shape checks
**Problem.** The Vapi API rejects bad configs at PATCH time with terse 400s ("property speed should not exist") — and by then the push has already partially completed against other resources. We watched the same five classes of mistake hit production over and over: 1. Assistant names (or eval names) longer than 40 chars (silent cap). 2. Structured-output ↔ assistant lockstep mismatch — one side declares the relationship, the other doesn't, dashboard ends up inconsistent. 3. Prompts duplicated by paste-on-top dashboard edits (10kB prompt with two identical headers stacked, agent follows both). 4. `maxTokens` set lower than the JSON-schema size of the attached tools' arguments — assistant looks fine on push, bricks on first tool-using call. 5. Voice fields nested wrong for the provider (`voice.speed` on Cartesia, where it lives at `voice.generationConfig.speed`). **What this fix does.** Five client-side validators, all running off the same `LoadedResources` shape that `push.ts` would actually ship — so the lint runs against exactly what would be pushed, no separate parser to drift. Surfaces as warnings by default (one bad spec doesn't block an otherwise-good push); promote to abort with `--strict`. Run standalone via `npm run validate -- <org>`. **Outcome you'll notice.** Most schema-class mistakes get caught locally in seconds instead of mid-push 400s. Voice provider field mismatch gets a specific message pointing at the right path. CI can add `npm run push -- <env> --strict` as a gate before any deploy. --- Catch the classes of errors that today only surface when the API returns a 400 mid-push. The push pipeline runs validation in warn-only mode by default; --strict promotes errors to a blocking abort before any API call. Standalone runner via `npm run validate -- <org>`. Validators implemented: 1. Name length cap (40 chars). Walks every assistant.name and every evaluations[].structuredOutput.name in scenarios. Closes #18. 2. SO ↔ assistant bidirectional lockstep. For every SO file's assistant_ids, checks the named assistant's structuredOutputIds mirrors it; reverse direction too. Closes #11. 3. Prompt duplication heuristics. Same H1 heading appearing twice, repeated CONTINUITY ON ENTRY / CLOSEOUT FLOW STRUCTURE blocks. Partial fix for #8 (paste-on-top dashboard duplications). 4. maxTokens floor for tool-using assistants. Computes floor ≈ 25 + sum(len(JSON.stringify(tool.function.parameters))) per attached tool. Warns under floor. Closes #19. 5. Per-provider voice schema. Cartesia rejects top-level speed / stability / similarityBoost / enableSsmlParsing (point at generationConfig.* / drop the field). 11labs rejects generationConfig (it's a Cartesia path). Closes #9 (engine half). - src/validate.ts (NEW): validateResources(loadedResources) returning ValidationFinding[] with severity / type / resourceId / rule / message / fieldPath. Pure data; safe to test directly. - src/validate-cmd.ts (NEW): CLI entry. Loads same resource shape as push.ts so the lint runs against exactly what would ship. Exit non-zero on any error finding. - src/config.ts: --strict flag. - src/push.ts: validators run in default-warn mode; --strict aborts. - package.json: validate script. - AGENTS.md: document npm run validate and --strict. - tests/validate.test.ts: per-rule fixtures (golden + bad inputs) covering all five checks. Closes improvements.md #11, #18, #19. Resolves engine half of #9. Partial #8, #20 (heuristic only). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 2630d0c commit bcd23de

8 files changed

Lines changed: 938 additions & 7 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,9 @@ npm run push -- <org> assistants # Push only assistants
748748
npm run push -- <org> resources/<org>/assistants/my-agent.md # Push single file
749749
npm run push -- <org> <path1> <path2> # Push multiple specific files (one state write)
750750
npm run push -- <org> --dry-run # Preview without applying any platform changes
751+
npm run push -- <org> --strict # Abort push if any validator returns an error
751752
npm run apply -- <org> # Pull then push (full sync)
753+
npm run validate -- <org> # Lint resources locally (fails fast on schema drift)
752754
753755
# Testing
754756
npm run call -- <org> -a <assistant-name> # Call an assistant via WebSocket

improvements.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,19 @@ you which stack PR closes the row.**
5959
| 5 | `push --dry-run` | Cheapest operator-safety win | None | RESOLVED 2026-04-30 (Stack C) |
6060
| 6 | API-level optimistic concurrency | Server-side conflict rejection | Platform | Deferred (Stack I, gated) |
6161
| 7 | Voice edits drop pronunciation-dictionary attachments | Silent regression on Cartesia + 11labs voice edits | #4 | Open (Stack G planned) |
62-
| 8 | Dashboard prompt edits can in-place duplicate the prompt | Two stacked prompt versions = stitched output | None | Open (Stack D planned) |
63-
| 9 | Provider-specific voice schema mismatch (push 400) | `voice.speed` vs `voice.generationConfig.speed` | None | Partial — doc cheat-sheet (Stack A) |
62+
| 8 | Dashboard prompt edits can in-place duplicate the prompt | Two stacked prompt versions = stitched output | None | Partial — Stack D heuristic |
63+
| 9 | Provider-specific voice schema mismatch (push 400) | `voice.speed` vs `voice.generationConfig.speed` | None | RESOLVED 2026-04-30 (Stack D + A) |
6464
| 10 | Targeted assistant push mints duplicate tools | Re-pushing assistant duplicates `end-call-*` tools | #4 | Partial |
65-
| 11 | Bidirectional SO ↔ assistant lockstep has no validation | One-sided edits silently inconsistent | None | Open (Stack D planned) |
65+
| 11 | Bidirectional SO ↔ assistant lockstep has no validation | One-sided edits silently inconsistent | None | RESOLVED 2026-04-30 (Stack D) |
6666
| 12 | State file accumulates UUIDs without source files | Silent gitops drift | None | Partial |
6767
| 13 | `.agent/` and `.claude/handoffs/` not gitignored | `git add -A` sweeps PII handoff scratch | None | RESOLVED 2026-04-30 (Stack A) |
6868
| 14 | Multi-file push undocumented | Discoverability | None | RESOLVED 2026-04-30 (Stack A) |
6969
| 15 | Scoped push rewrites entire state file | Pre-existing drift sweeps into focused commits | #4 | Open (Stack J planned) |
7070
| 16 | No CLI runner for simulation suites | Engine pushes them, can't run them | None | Open (Stack E planned) |
7171
| 17 | State file key-order churn produces noisy diffs | Reorderings hide real changes | None | RESOLVED 2026-04-30 (Stack B) |
72-
| 18 | Structured-output `name` capped at 40 chars (no warning) | Push fails partway after partial application | None | Open (Stack D planned) |
73-
| 19 | No `maxTokens` floor warning for tool-using assistants | `maxTokens: 1` bricks the assistant silently | None | Open (Stack D planned) |
74-
| 20 | Prompt vocabulary leaks into TTS | `Reason.` becomes verbal contaminant | None | Open (Stack D heuristic planned) |
72+
| 18 | Structured-output `name` capped at 40 chars (no warning) | Push fails partway after partial application | None | RESOLVED 2026-04-30 (Stack D) |
73+
| 19 | No `maxTokens` floor warning for tool-using assistants | `maxTokens: 1` bricks the assistant silently | None | RESOLVED 2026-04-30 (Stack D) |
74+
| 20 | Prompt vocabulary leaks into TTS | `Reason.` becomes verbal contaminant | None | Partial — Stack D heuristic |
7575

7676
---
7777

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"call": "bash -c 'exec tsx src/call-cmd.ts \"$@\" 2> >(grep --line-buffered -v \"buffer underflow\" >&2)' --",
1414
"cleanup": "tsx src/cleanup-cmd.ts",
1515
"eval": "tsx src/eval.ts",
16+
"validate": "tsx src/validate-cmd.ts",
1617
"build": "tsc --noEmit",
1718
"test": "node --import tsx --test tests/*.test.ts"
1819
},

src/config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,21 @@ function parseFlags(): {
8686
forceDelete: boolean;
8787
bootstrapSync: boolean;
8888
dryRun: boolean;
89+
strictValidation: boolean;
8990
applyFilter: ApplyFilter;
9091
} {
9192
const args = process.argv.slice(3);
9293
const result: {
9394
forceDelete: boolean;
9495
bootstrapSync: boolean;
9596
dryRun: boolean;
97+
strictValidation: boolean;
9698
applyFilter: ApplyFilter;
9799
} = {
98100
forceDelete: args.includes("--force"),
99101
bootstrapSync: args.includes("--bootstrap"),
100102
dryRun: args.includes("--dry-run"),
103+
strictValidation: args.includes("--strict"),
101104
applyFilter: {},
102105
};
103106

@@ -108,7 +111,12 @@ function parseFlags(): {
108111
const arg = args[i];
109112
if (!arg) continue;
110113

111-
if (arg === "--force" || arg === "--bootstrap" || arg === "--dry-run")
114+
if (
115+
arg === "--force" ||
116+
arg === "--bootstrap" ||
117+
arg === "--dry-run" ||
118+
arg === "--strict"
119+
)
112120
continue;
113121

114122
// --confirm <slug>: consumed by cleanup.ts directly. Eat the value here so
@@ -243,6 +251,7 @@ export const {
243251
forceDelete: FORCE_DELETE,
244252
bootstrapSync: BOOTSTRAP_SYNC,
245253
dryRun: DRY_RUN,
254+
strictValidation: STRICT_VALIDATION,
246255
applyFilter: APPLY_FILTER,
247256
} = parseFlags();
248257

src/push.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import {
66
VAPI_BASE_URL,
77
FORCE_DELETE,
88
DRY_RUN,
9+
STRICT_VALIDATION,
910
APPLY_FILTER,
1011
BASE_DIR,
1112
removeExcludedKeys,
1213
} from "./config.ts";
14+
import { summarizeFindings, validateResources } from "./validate.ts";
1315
import { loadState, saveState } from "./state.ts";
1416
import { loadResources, loadSingleResource, FOLDER_MAP } from "./resources.ts";
1517
import { fetchAllResources, resourceIdMatchesName, runPull } from "./pull.ts";
@@ -909,6 +911,30 @@ async function main(): Promise<void> {
909911

910912
state = await maybeBootstrapState(loadedResources, state);
911913

914+
// Run client-side validators against the loaded resource set. In default
915+
// mode, errors are surfaced as warnings so a single bad spec doesn't block
916+
// an otherwise-good push. With --strict, any error-severity finding aborts
917+
// before any API call.
918+
console.log("\n🔎 Running validators...");
919+
const findings = validateResources(loadedResources);
920+
if (findings.length > 0) {
921+
console.log(summarizeFindings(findings));
922+
} else {
923+
console.log(" ✅ No validation issues.");
924+
}
925+
const errorCount = findings.filter((f) => f.severity === "error").length;
926+
if (errorCount > 0) {
927+
if (STRICT_VALIDATION) {
928+
console.error(
929+
`\n❌ Validation failed (${errorCount} error(s)). --strict refuses to push. Fix the issues above or drop --strict.`,
930+
);
931+
process.exit(1);
932+
}
933+
console.warn(
934+
` ⚠️ ${errorCount} validation error(s) detected — push will continue (use --strict to abort on errors).`,
935+
);
936+
}
937+
912938
// Resolve credential names → UUIDs in all resource data before applying
913939
const credMap = credentialForwardMap(state);
914940
if (credMap.size > 0) {

src/validate-cmd.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// CLI entry: `npm run validate -- <org>`
2+
//
3+
// Loads the same resource shape as `push.ts` would (so the validator runs
4+
// against exactly what would ship), then runs all client-side validators
5+
// and prints findings. Exit code 0 if no errors, 1 if any error-severity
6+
// finding is present.
7+
8+
import { resolve } from "path";
9+
import { fileURLToPath } from "url";
10+
import { VAPI_ENV, VAPI_BASE_URL } from "./config.ts";
11+
import { loadResources } from "./resources.ts";
12+
import { summarizeFindings, validateResources } from "./validate.ts";
13+
import type { LoadedResources } from "./types.ts";
14+
15+
async function main(): Promise<void> {
16+
console.log(
17+
"═══════════════════════════════════════════════════════════════",
18+
);
19+
console.log(`🔎 Vapi GitOps Validate - Environment: ${VAPI_ENV}`);
20+
console.log(` API: ${VAPI_BASE_URL}`);
21+
console.log(
22+
"═══════════════════════════════════════════════════════════════\n",
23+
);
24+
25+
console.log("📂 Loading resources...\n");
26+
const resources: LoadedResources = {
27+
tools: await loadResources("tools"),
28+
structuredOutputs: await loadResources("structuredOutputs"),
29+
assistants: await loadResources("assistants"),
30+
squads: await loadResources("squads"),
31+
personalities: await loadResources("personalities"),
32+
scenarios: await loadResources("scenarios"),
33+
simulations: await loadResources("simulations"),
34+
simulationSuites: await loadResources("simulationSuites"),
35+
evals: await loadResources("evals"),
36+
};
37+
38+
const findings = validateResources(resources);
39+
console.log(`\n${summarizeFindings(findings)}\n`);
40+
41+
const errorCount = findings.filter((f) => f.severity === "error").length;
42+
if (errorCount > 0) {
43+
console.error(
44+
`❌ Validation failed with ${errorCount} error(s). Fix the issues above before pushing.`,
45+
);
46+
process.exit(1);
47+
}
48+
console.log("✅ Validation passed.");
49+
}
50+
51+
const isMainModule =
52+
process.argv[1] !== undefined &&
53+
resolve(process.argv[1]) === fileURLToPath(import.meta.url);
54+
55+
if (isMainModule) {
56+
main().catch((error) => {
57+
console.error(
58+
"\n❌ Validation failed:",
59+
error instanceof Error ? error.message : error,
60+
);
61+
process.exit(1);
62+
});
63+
}

0 commit comments

Comments
 (0)