Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,9 @@ npm run push -- <org> assistants # Push only assistants
npm run push -- <org> resources/<org>/assistants/my-agent.md # Push single file
npm run push -- <org> <path1> <path2> # Push multiple specific files (one state write)
npm run push -- <org> --dry-run # Preview without applying any platform changes
npm run push -- <org> --strict # Abort push if any validator returns an error
npm run apply -- <org> # Pull then push (full sync)
npm run validate -- <org> # Lint resources locally (fails fast on schema drift)

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

---

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"call": "bash -c 'exec tsx src/call-cmd.ts \"$@\" 2> >(grep --line-buffered -v \"buffer underflow\" >&2)' --",
"cleanup": "tsx src/cleanup-cmd.ts",
"eval": "tsx src/eval.ts",
"validate": "tsx src/validate-cmd.ts",
"build": "tsc --noEmit",
"test": "node --import tsx --test tests/*.test.ts"
},
Expand Down
11 changes: 10 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,21 @@ function parseFlags(): {
forceDelete: boolean;
bootstrapSync: boolean;
dryRun: boolean;
strictValidation: boolean;
applyFilter: ApplyFilter;
} {
const args = process.argv.slice(3);
const result: {
forceDelete: boolean;
bootstrapSync: boolean;
dryRun: boolean;
strictValidation: boolean;
applyFilter: ApplyFilter;
} = {
forceDelete: args.includes("--force"),
bootstrapSync: args.includes("--bootstrap"),
dryRun: args.includes("--dry-run"),
strictValidation: args.includes("--strict"),
applyFilter: {},
};

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

if (arg === "--force" || arg === "--bootstrap" || arg === "--dry-run")
if (
arg === "--force" ||
arg === "--bootstrap" ||
arg === "--dry-run" ||
arg === "--strict"
)
continue;

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

Expand Down
26 changes: 26 additions & 0 deletions src/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
VAPI_BASE_URL,
FORCE_DELETE,
DRY_RUN,
STRICT_VALIDATION,
APPLY_FILTER,
BASE_DIR,
removeExcludedKeys,
} from "./config.ts";
import { summarizeFindings, validateResources } from "./validate.ts";
import { loadState, saveState } from "./state.ts";
import { loadResources, loadSingleResource, FOLDER_MAP } from "./resources.ts";
import { fetchAllResources, resourceIdMatchesName, runPull } from "./pull.ts";
Expand Down Expand Up @@ -909,6 +911,30 @@ async function main(): Promise<void> {

state = await maybeBootstrapState(loadedResources, state);

// Run client-side validators against the loaded resource set. In default
// mode, errors are surfaced as warnings so a single bad spec doesn't block
// an otherwise-good push. With --strict, any error-severity finding aborts
// before any API call.
console.log("\n🔎 Running validators...");
const findings = validateResources(loadedResources);
if (findings.length > 0) {
console.log(summarizeFindings(findings));
} else {
console.log(" ✅ No validation issues.");
}
const errorCount = findings.filter((f) => f.severity === "error").length;
if (errorCount > 0) {
if (STRICT_VALIDATION) {
console.error(
`\n❌ Validation failed (${errorCount} error(s)). --strict refuses to push. Fix the issues above or drop --strict.`,
);
process.exit(1);
}
console.warn(
` ⚠️ ${errorCount} validation error(s) detected — push will continue (use --strict to abort on errors).`,
);
}

// Resolve credential names → UUIDs in all resource data before applying
const credMap = credentialForwardMap(state);
if (credMap.size > 0) {
Expand Down
63 changes: 63 additions & 0 deletions src/validate-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// CLI entry: `npm run validate -- <org>`
//
// Loads the same resource shape as `push.ts` would (so the validator runs
// against exactly what would ship), then runs all client-side validators
// and prints findings. Exit code 0 if no errors, 1 if any error-severity
// finding is present.

import { resolve } from "path";
import { fileURLToPath } from "url";
import { VAPI_ENV, VAPI_BASE_URL } from "./config.ts";
import { loadResources } from "./resources.ts";
import { summarizeFindings, validateResources } from "./validate.ts";
import type { LoadedResources } from "./types.ts";

async function main(): Promise<void> {
console.log(
"═══════════════════════════════════════════════════════════════",
);
console.log(`🔎 Vapi GitOps Validate - Environment: ${VAPI_ENV}`);
console.log(` API: ${VAPI_BASE_URL}`);
console.log(
"═══════════════════════════════════════════════════════════════\n",
);

console.log("📂 Loading resources...\n");
const resources: LoadedResources = {
tools: await loadResources("tools"),
structuredOutputs: await loadResources("structuredOutputs"),
assistants: await loadResources("assistants"),
squads: await loadResources("squads"),
personalities: await loadResources("personalities"),
scenarios: await loadResources("scenarios"),
simulations: await loadResources("simulations"),
simulationSuites: await loadResources("simulationSuites"),
evals: await loadResources("evals"),
};

const findings = validateResources(resources);
console.log(`\n${summarizeFindings(findings)}\n`);

const errorCount = findings.filter((f) => f.severity === "error").length;
if (errorCount > 0) {
console.error(
`❌ Validation failed with ${errorCount} error(s). Fix the issues above before pushing.`,
);
process.exit(1);
}
console.log("✅ Validation passed.");
}

const isMainModule =
process.argv[1] !== undefined &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (isMainModule) {
main().catch((error) => {
console.error(
"\n❌ Validation failed:",
error instanceof Error ? error.message : error,
);
process.exit(1);
});
}
Loading