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 @@ -90,6 +90,7 @@ If setup reports the org is "already set up locally", do not delete anything to
| Push with new resources | `npm run push -- <org> --allow-new-files` — bypass orphan-YAML gate. **AI agents**: do NOT auto-pass this flag; confirm with the human first (see push section below) |
| Test a call | `npm run call -- <org> -a <assistant-name>` or `-s <squad-name>` |
| Run a simulation suite | `npm run sim -- <org> --suite <name> --target <assistant-name>` |
| Build PR-check payloads offline | `npm run check -- <check> --dry-run` (or `--all`); `--print-payload` writes the JSON |
| Migrate a legacy state file | `npm run migrate` — one-shot, all orgs; required once after upgrading to the hash-store engine |

---
Expand Down Expand Up @@ -903,6 +904,7 @@ npm run validate -- <org> # Lint resources locally (fai
npm run audit -- <org> # Read-only drift detector: orphan YAML, state ghosts, content-identical clusters, sibling base-slugs, dashboard orphans, inline model.tools. Exit 1 on findings.
npm run audit -- <org> --type assistants # Scope audit to a single resource type
npm run sim -- <org> --suite <name> --target <name> # Run a simulation suite against an assistant/squad (exit 0 pass, 1 fail, 3 incomplete; --timeout <min>)
npm run check -- <check> --dry-run # Build a vapi-checks.yml check's inline run payload offline (exit 0 built, 2 config/build error; --print-payload [dir])
npm run rollback -- <org> --to <ISO-timestamp> # Re-apply a snapshot taken before a push
npm run rollback -- <org> --list # List available snapshots

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Every command works in two modes:
| `npm run rollback` | — | `npm run rollback -- <org> --list` or `--to <ISO>` | Restore from a snapshot in `.vapi-state.<org>.snapshots/` (one is written before every push/apply). |
| `npm run call` | ✅ | `npm run call -- <org> -a <name>` or `-s <squad>` | Start an interactive WebSocket call against an assistant or squad. |
| `npm run sim` | — | `npm run sim -- <org> --suite <name> --target <name> [--timeout <min>]` | Run a simulation suite (or specific simulations) against a deployed assistant/squad. Prints the run link; exits 0 passed, 1 failed, 3 incomplete (timeout, Ctrl-C, missing results). |
| `npm run check` | — | `npm run check -- <check>\|--all --dry-run [--print-payload [dir]]` | Build each `vapi-checks.yml` check target's inline simulation-run payload from the files on disk, with its tools, handoffs, judges and personalities. Offline: no API key, nothing sent. `--print-payload` writes the JSON (default `tmp/check-payloads/`). Exits 0 when every payload builds, 2 on a config or build error. |
| `npm run migrate` | — | `npm run migrate` | One-time, all orgs at once: slim legacy state files to pure `name → uuid` and seed the per-developer `.vapi-state-hash/` baseline store from the old hashes. Required once after upgrading to the hash-store engine — `pull`/`push`/`apply` refuse legacy-shaped state until it runs. Idempotent. |
| `npm run build` | — | — | Type-check the codebase (`tsc --noEmit`). |
| `npm test` | — | — | Run regression tests (`node:test`). |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"validate": "tsx src/validate-cmd.ts",
"audit": "tsx src/audit-cmd.ts",
"sim": "tsx src/sim-cmd.ts",
"check": "tsx src/check-cmd.ts",
"rollback": "tsx src/rollback-cmd.ts",
"promote": "tsx src/promote-cmd.ts",
"build": "tsc --noEmit",
Expand Down
226 changes: 226 additions & 0 deletions src/check-cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// CLI entry: `npm run check -- <check>|--all --dry-run [--print-payload [dir]]`
//
// Builds each check target's inline simulation-run payload from the files
// on disk (see check-payload.ts) and reports what would run. Config-free:
// it needs no API key and makes no network calls in --dry-run.
//
// Exit codes: 0 every payload built, 2 usage, config or build error.

import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { CheckDefinition } from "./check-config.ts";
import { CHECKS_CONFIG_FILE, checksConfigLoad } from "./check-config.ts";
import type { CheckPayloadResult } from "./check-payload.ts";
import { checkPayloadBuild } from "./check-payload.ts";
import { promotionBindingsResolve, promotionStateParse } from "./promotion.ts";
import { orgResourcesRead } from "./resource-parse.ts";
import type { StateFile } from "./types.ts";

const USAGE = [
"Usage:",
" npm run check -- <check> --dry-run [--print-payload [dir]]",
" npm run check -- --all --dry-run [--print-payload [dir]]",
"",
"Options:",
` <check> A check name from ${CHECKS_CONFIG_FILE}`,
" --all Every check",
" --dry-run Build the payloads offline; nothing is sent",
" --print-payload [dir] Write each payload as JSON (default: tmp/check-payloads)",
"",
"Exit codes: 0 payloads built, 2 usage, config or build error",
].join("\n");

const DEFAULT_PAYLOAD_DIR = "tmp/check-payloads";

interface CheckArgs {
check?: string;
all: boolean;
dryRun: boolean;
payloadDir?: string;
help: boolean;
}

class UsageError extends Error {}

function argsParse(args: string[]): CheckArgs {
const parsed: CheckArgs = { all: false, dryRun: false, help: false };
for (let index = 0; index < args.length; index++) {
const arg = args[index]!;
if (arg === "--help" || arg === "-h") parsed.help = true;
else if (arg === "--all") parsed.all = true;
else if (arg === "--dry-run") parsed.dryRun = true;
else if (arg === "--print-payload") {
const next = args[index + 1];
parsed.payloadDir =
next && !next.startsWith("--") ? args[++index] : DEFAULT_PAYLOAD_DIR;
} else if (arg.startsWith("-"))
throw new UsageError(`Unknown option: ${arg}`);
else if (parsed.check) throw new UsageError(`Unexpected argument: ${arg}`);
else parsed.check = arg;
}
if (parsed.help) return parsed;
if (parsed.all === Boolean(parsed.check))
throw new UsageError("Name one check, or pass --all");
return parsed;
}

function emptyState(): StateFile {
return promotionStateParse("{}");
}

function stateRead(root: string, org: string, warnings: string[]): StateFile {
const path = join(root, `.vapi-state.${org}.json`);
if (!existsSync(path)) {
warnings.push(
`no .vapi-state.${org}.json: references by UUID and credential bindings can't resolve`,
);
return emptyState();
}
return promotionStateParse(readFileSync(path, "utf8"));
}

function payloadFileName(check: string, target: string): string {
return `${check}--${target.replace(/\//g, "--")}.json`;
}

function resultPrint(
label: string,
result: CheckPayloadResult,
check: CheckDefinition,
): void {
console.log(`\n${label}`);
if (result.body) {
const count = result.body.simulations.length;
console.log(
` ✅ ${count} simulation${count === 1 ? "" : "s"} × ${check.iterations} iteration${check.iterations === 1 ? "" : "s"} over ${check.transport}, ${(result.bytes / 1024).toFixed(1)} KB`,
);
} else {
console.log(
` ❌ ${result.errors.length} problem${result.errors.length === 1 ? "" : "s"}`,
);
}
for (const warning of result.warnings) console.log(` ⚠️ ${warning}`);
for (const error of result.errors) console.log(` ❌ ${error}`);
}

async function checkDryRun(
root: string,
check: CheckDefinition,
payloadDir: string | undefined,
): Promise<boolean> {
if (!existsSync(join(root, "resources", check.org))) {
console.log(`\n${check.name}\n ❌ resources/${check.org}/ does not exist`);
return false;
}
const warnings: string[] = [];
const resources = await orgResourcesRead(root, check.org);
const sourceState = stateRead(root, check.org, warnings);
const runState =
check.runOrg === check.org
? sourceState
: stateRead(root, check.runOrg, warnings);
const bindingsResolved = await promotionBindingsResolve(
root,
check.org,
check.runOrg,
sourceState,
);
let ok = true;
for (const target of check.targets) {
const targetLabel = `${target.type}/${target.id}`;
const result = checkPayloadBuild({
check,
target,
resources,
sourceState,
runState,
bindingsResolved,
});
result.warnings.unshift(...warnings);
resultPrint(`${check.name} / ${targetLabel}`, result, check);
if (!result.body) {
ok = false;
continue;
}
if (payloadDir) {
const dir = resolve(root, payloadDir);
mkdirSync(dir, { recursive: true });
const file = join(dir, payloadFileName(check.name, targetLabel));
writeFileSync(file, `${JSON.stringify(result.body, null, 2)}\n`);
console.log(` 📝 ${relative(root, file)}`);
}
}
return ok;
}

export async function checkCommandRun(
args = process.argv.slice(2),
root = resolve(
process.env.VAPI_GITOPS_ROOT ??
fileURLToPath(new URL("..", import.meta.url)),
),
): Promise<number> {
let parsed: CheckArgs;
try {
parsed = argsParse(args);
} catch (error) {
if (!(error instanceof UsageError)) throw error;
console.error(`❌ ${error.message}\n\n${USAGE}`);
return 2;
}
if (parsed.help) {
console.log(USAGE);
return 0;
}
let config;
try {
config = checksConfigLoad(root);
} catch (error) {
console.error(`❌ ${CHECKS_CONFIG_FILE}: ${(error as Error).message}`);
return 2;
}
if (!config) {
console.log(
`No ${CHECKS_CONFIG_FILE} at the repository root; nothing to check.`,
);
return 0;
}
const names = parsed.all ? Object.keys(config.checks) : [parsed.check!];
const missing = names.filter((name) => !config.checks[name]);
if (missing.length > 0) {
console.error(
`❌ No check named ${missing.join(", ")} in ${CHECKS_CONFIG_FILE} (checks: ${Object.keys(config.checks).join(", ")})`,
);
return 2;
}
if (!parsed.dryRun) {
console.error(
"❌ Live runs aren't available yet: pass --dry-run to build the payloads offline.",
);
return 2;
}
let ok = true;
for (const name of names) {
if (!(await checkDryRun(root, config.checks[name]!, parsed.payloadDir)))
ok = false;
}
console.log(
ok ? "\n✅ Every payload built." : "\n❌ Some payloads could not be built.",
);
return ok ? 0 : 2;
}

const isMainModule =
resolve(process.argv[1] ?? "") === resolve(fileURLToPath(import.meta.url));
if (isMainModule) {
checkCommandRun().then(
(code) => process.exit(code),
(error: unknown) => {
console.error(
`❌ Check failed: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(2);
},
);
}
Loading
Loading