aui-agent-builder 0.3.91 → 0.3.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,168 +1,310 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
- * validate command - Validate AUI files against domain schemas (*.dschema.json)
3
+ * validate command Validate AUI files against the agent-settings backend.
4
4
  *
5
- * Convention-based mapping:
6
- * tools.dschema.json → tools/*.aui.json (validates the "tool" key)
7
- * agent.dschema.json → agent.aui.json (validates the "agent" or "general_settings" key)
8
- * parameters.dschema.json → parameters.aui.json (validates the "parameters" key)
9
- * entities.dschema.json → entities.aui.json (validates the "entities" key)
10
- * integrations.dschema.json integrations.aui.json (validates the "integrations" key)
11
- * rules.dschema.json → rules.aui.json (validates the "rules" key)
5
+ * ─── HOW THIS COMMAND WORKS NOW ─────────────────────────────────────────
6
+ *
7
+ * Validation is performed REMOTELY against
8
+ * POST {agent-settings}/v1/agents/{agent_id}/validate
9
+ *
10
+ * The CLI reads every `.aui.json` file in the project, assembles the flat
11
+ * payload the backend expects (general_settings + parameters + entities +
12
+ * rules + integrations + tools), and forwards it to the backend's
13
+ * canonical pydantic + cross-reference validator. The response is mapped
14
+ * back onto local file paths so the existing `ValidateView` can render
15
+ * the errors/warnings exactly as it did when validation ran locally.
16
+ *
17
+ * The previous local implementation (AJV + JS cross-reference checks) is
18
+ * preserved INLINE BELOW inside the `LEGACY_LOCAL_VALIDATION_*` regions
19
+ * as a JS comment so we can revive it for offline or backend-down
20
+ * scenarios without re-deriving the logic from git history. Do not delete
21
+ * those regions — they are a deliberate fall-back snapshot.
22
+ *
23
+ * ─── PUSH FLOW SAFETY ───────────────────────────────────────────────────
24
+ *
25
+ * `aui push` invokes `validate(projectRoot)` in its preflight span and
26
+ * aborts the push (with `ValidationError`) whenever validation returns
27
+ * false — UNLESS `--force` is set. We preserve that contract here: any
28
+ * validation error (backend-reported or infra failure) returns false.
29
+ *
30
+ * • Backend says `valid: false` → return false (push blocked)
31
+ * • Backend returns 4xx/5xx → return false (push blocked)
32
+ * • Network/timeout failure → return false (push blocked)
33
+ * • Backend returns `valid: true` → return true (push proceeds)
34
+ *
35
+ * Push-time safety is thus exactly what it was before: a failed
36
+ * validation never silently lets a push through.
12
37
  *
13
38
  * Usage: aui validate [path]
14
39
  * Options:
15
40
  * --verbose Show detailed output
41
+ * --strict Reserved (no-op against the remote validator today;
42
+ * kept so existing scripts that pass it don't break)
16
43
  */
17
44
  import * as fs from "fs";
18
45
  import * as path from "path";
19
46
  import { fileURLToPath } from "url";
20
- import { execSync } from "child_process";
21
47
  import { render } from "ink";
22
- import AjvModule from "ajv";
23
- const Ajv = AjvModule.default ?? AjvModule;
24
48
  import { glob } from "glob";
25
- import { findProjectRoot } from "../config/index.js";
26
- import { parseAuiFile, readAgentFiles, } from "../utils/index.js";
49
+ import { findProjectRoot, loadProjectConfig, loadSession, getConfig, } from "../config/index.js";
50
+ import { parseAuiFile, } from "../utils/index.js";
27
51
  import { ValidateView } from "../ui/views/ValidateView.js";
28
52
  import { ValidationError } from "../errors/index.js";
29
53
  import { isJsonMode, outputJson } from "../utils/json-output.js";
30
- import { getTracer, SpanStatusCode, setUserContext } from "../telemetry.js";
54
+ import { getTracer, SpanStatusCode, setUserContext, setAgentContext, } from "../telemetry.js";
55
+ import { AUIClient, AUIAPIError } from "../api-client/index.js";
31
56
  const __filename = fileURLToPath(import.meta.url);
32
57
  const __dirname = path.dirname(__filename);
58
+ /** Schema-version pinned by the backend validator. */
59
+ const VALIDATE_SCHEMA_VERSION = 1;
33
60
  /**
34
- * Tool codes that must never be renamed.
35
- * If a tool file's original code (derived from filename) is in this list,
36
- * the code field inside the file must match exactly.
61
+ * Map a backend section name to the local file name we surface in the UI.
62
+ * Tools are handled separately each tool lives in its own file under
63
+ * `tools/<code>.aui.json` and we resolve the code via the payload index.
37
64
  */
38
- const IMMUTABLE_TOOL_CODES = ["WEB_SEARCH"];
39
- /**
40
- * Schema types whose validation issues should be reported as warnings, not errors.
41
- */
42
- const WARNING_ONLY_TYPES = new Set(["questions"]);
43
- /** Map schema type name to the JSON key that holds the data inside the .aui.json file */
44
- const TYPE_TO_KEY = {
45
- tools: "tool",
46
- agent: "agent",
47
- parameters: "parameters",
48
- entities: "entities",
49
- integrations: "integrations",
50
- rules: "rules",
51
- // "knowledge-bases": null,
52
- // "knowledge-bases-files": null,
65
+ const SECTION_TO_FILE = {
66
+ general_settings: "agent.aui.json",
67
+ parameters: "parameters.aui.json",
68
+ entities: "entities.aui.json",
69
+ rules: "rules.aui.json",
70
+ integrations: "integrations.aui.json",
53
71
  };
54
72
  /**
55
- * Discover all *.dschema.json files.
56
- * Scans bundled dist/schema/ first, then recursively searches the given root
57
- * for any *.dschema.json files. Later matches override earlier ones (local wins).
73
+ * Discover all `.aui.json` files (excluding any helper / cache dirs).
74
+ * Returns absolute paths.
58
75
  */
59
- async function discoverSchemas(searchRoot) {
60
- const schemas = new Map();
61
- // 1. Bundled schemas (dist/schema/)
62
- const bundledDir = path.resolve(__dirname, "..", "schema");
63
- if (fs.existsSync(bundledDir)) {
64
- for (const file of fs.readdirSync(bundledDir)) {
65
- const match = file.match(/^(.+)\.dschema\.json$/);
66
- if (match) {
67
- schemas.set(match[1], path.join(bundledDir, file));
68
- }
69
- }
70
- }
71
- // 2. Scan searchRoot recursively for any *.dschema.json
72
- const localFiles = await glob("**/*.dschema.json", {
73
- cwd: searchRoot,
76
+ async function discoverAuiFiles(projectRoot) {
77
+ return glob("**/*.aui.json", {
78
+ cwd: projectRoot,
74
79
  absolute: true,
75
80
  ignore: ["node_modules/**", ".aui/**", ".aui-cache/**"],
76
81
  });
77
- for (const filePath of localFiles) {
78
- const base = path.basename(filePath);
79
- const match = base.match(/^(.+)\.dschema\.json$/);
80
- if (match) {
81
- schemas.set(match[1], filePath);
82
- }
83
- }
84
- return schemas;
85
82
  }
86
83
  /**
87
- * Find .aui.json files that match a given schema type within the project.
88
- * - "tools" tools/*.aui.json
89
- * - other types <type>.aui.json
84
+ * Read every project file and build the flat payload expected by
85
+ * `POST /v1/agents/{id}/validate`. Returns the payload plus an index
86
+ * mapping backend section indexes back to local file names so we can
87
+ * surface accurate file paths in the UI.
88
+ *
89
+ * Files that fail to parse are NOT included in the payload — they are
90
+ * returned in `parseErrors` and surfaced as local validation issues so
91
+ * the backend doesn't choke on syntactically invalid JSON.
90
92
  */
91
- async function findFilesForType(projectRoot, typeName) {
92
- // if (typeName === "knowledge-bases" || typeName === "knowledge-bases-files") {
93
- // return glob("knowledge-hubs/*/kb.json", {
94
- // cwd: projectRoot,
95
- // absolute: true,
96
- // ignore: ["node_modules/**", ".aui/**", ".aui-cache/**"],
97
- // });
98
- // }
99
- const pattern = typeName === "tools" ? "**/tools/*.aui.json" : `${typeName}*.aui.json`;
100
- return glob(pattern, {
101
- cwd: projectRoot,
102
- absolute: true,
103
- ignore: ["node_modules/**", ".aui/**", ".aui-cache/**"],
104
- });
93
+ async function buildValidatePayload(projectRoot) {
94
+ const files = await discoverAuiFiles(projectRoot);
95
+ const parseErrors = [];
96
+ const validatedFiles = [];
97
+ const payload = {
98
+ schema_version: VALIDATE_SCHEMA_VERSION,
99
+ };
100
+ const toolFiles = [];
101
+ // Sort tool files alphabetically by basename so the payload index is
102
+ // deterministic between runs (important for diffing & log correlation).
103
+ const toolFilesAll = [];
104
+ for (const f of files) {
105
+ const rel = path.relative(projectRoot, f);
106
+ if (rel.startsWith("tools/") || rel.startsWith("tools\\")) {
107
+ toolFilesAll.push(f);
108
+ }
109
+ }
110
+ toolFilesAll.sort();
111
+ for (const file of files) {
112
+ const rel = path.relative(projectRoot, file);
113
+ const parsed = parseAuiFile(file);
114
+ if (!parsed) {
115
+ parseErrors.push({
116
+ type: "error",
117
+ file: rel,
118
+ message: "Invalid JSON syntax — file could not be parsed",
119
+ });
120
+ continue;
121
+ }
122
+ const isToolFile = rel.startsWith("tools/") || rel.startsWith("tools\\");
123
+ if (isToolFile) {
124
+ // Handled later via toolFilesAll to keep deterministic index order
125
+ continue;
126
+ }
127
+ if (rel === "agent.aui.json" || parsed.general_settings) {
128
+ const gs = (parsed.general_settings ?? parsed);
129
+ payload.general_settings = gs;
130
+ validatedFiles.push(rel);
131
+ continue;
132
+ }
133
+ if (parsed.parameters && Array.isArray(parsed.parameters)) {
134
+ payload.parameters = parsed.parameters;
135
+ validatedFiles.push(rel);
136
+ continue;
137
+ }
138
+ if (parsed.entities && Array.isArray(parsed.entities)) {
139
+ payload.entities = parsed.entities;
140
+ validatedFiles.push(rel);
141
+ continue;
142
+ }
143
+ if (parsed.integrations && Array.isArray(parsed.integrations)) {
144
+ payload.integrations = parsed.integrations;
145
+ validatedFiles.push(rel);
146
+ continue;
147
+ }
148
+ if (parsed.rules && Array.isArray(parsed.rules)) {
149
+ payload.rules = parsed.rules;
150
+ validatedFiles.push(rel);
151
+ continue;
152
+ }
153
+ }
154
+ // Build tools array preserving deterministic ordering.
155
+ const tools = [];
156
+ for (const file of toolFilesAll) {
157
+ const rel = path.relative(projectRoot, file);
158
+ const parsed = parseAuiFile(file);
159
+ if (!parsed) {
160
+ // Already captured above in parseErrors. Skip to keep array dense.
161
+ continue;
162
+ }
163
+ const toolObj = (parsed.tool ?? parsed);
164
+ tools.push(toolObj);
165
+ toolFiles.push(rel);
166
+ validatedFiles.push(rel);
167
+ }
168
+ if (tools.length > 0) {
169
+ payload.tools = tools;
170
+ }
171
+ return {
172
+ payload,
173
+ index: {
174
+ toolFiles,
175
+ validatedFiles,
176
+ totalFiles: validatedFiles.length,
177
+ },
178
+ parseErrors,
179
+ };
105
180
  }
106
181
  /**
107
- * Convert AJV errors to ValidationIssue format
182
+ * Convert a single backend `AgentValidateIssue` into the CLI's
183
+ * `ValidationIssue` shape so existing UI/JSON consumers don't see a new
184
+ * envelope. `loc` is rendered as a dot-path with bracket indexes for
185
+ * arrays — matches what FastAPI prints in its docs and what users will
186
+ * see in their browser's network tab if they ever inspect the endpoint
187
+ * directly.
108
188
  */
109
- function ajvErrorsToIssues(errors, relativePath, issueType = "error") {
110
- if (!errors)
111
- return [];
112
- return errors.map((err) => ({
113
- type: issueType,
114
- file: relativePath,
115
- path: err.instancePath || "/",
116
- message: `${err.instancePath || "/"} ${err.message || "validation error"}${err.params ? ` (${JSON.stringify(err.params)})` : ""}`,
117
- }));
189
+ function backendIssueToValidationIssue(issue, file, sectionPath) {
190
+ const severity = issue.severity === "warning" ? "warning" : "error";
191
+ const loc = (issue.loc ?? [])
192
+ .map((p) => (typeof p === "number" ? `[${p}]` : `.${p}`))
193
+ .join("")
194
+ .replace(/^\./, "");
195
+ const fullPath = loc ? `${sectionPath}.${loc}`.replace(/\.\[/g, "[") : sectionPath;
196
+ const code = issue.code ? `[${issue.code}] ` : "";
197
+ return {
198
+ type: severity,
199
+ file,
200
+ path: fullPath,
201
+ message: `${code}${issue.message}${loc ? ` (at ${loc})` : ""}`,
202
+ };
118
203
  }
119
204
  /**
120
- * Patch recursive "When" definitions in a schema so that AJV's anyOf
121
- * discrimination works correctly. Without additionalProperties: false on
122
- * When-type objects, any object (including a Condition with an invalid
123
- * operation like "equals") silently matches the When branch of anyOf.
205
+ * Map a per-section result onto local file paths.
206
+ *
207
+ * `general_settings` is a single object (no index). All other sections
208
+ * are arrays where each element carries its own index into the original
209
+ * payload. Tools additionally need their code looked up via the payload
210
+ * index because each tool lives in its own file on disk.
124
211
  */
125
- function patchRecursiveWhenDefs(schema) {
126
- const defs = schema.$defs;
127
- if (!defs)
128
- return;
129
- for (const [name, def] of Object.entries(defs)) {
130
- if (!name.endsWith("When") && !name.endsWith("RuleWhen"))
212
+ function mapResponseToIssues(response, payloadIndex) {
213
+ const issues = [];
214
+ // ── general_settings ──
215
+ const gs = response.general_settings;
216
+ if (gs) {
217
+ const file = SECTION_TO_FILE.general_settings;
218
+ for (const err of gs.errors ?? []) {
219
+ issues.push(backendIssueToValidationIssue({ ...err, severity: err.severity ?? "error" }, file, "general_settings"));
220
+ }
221
+ for (const w of gs.warnings ?? []) {
222
+ issues.push(backendIssueToValidationIssue({ ...w, severity: w.severity ?? "warning" }, file, "general_settings"));
223
+ }
224
+ }
225
+ // ── array sections (parameters, entities, rules, integrations) ──
226
+ const arraySections = [
227
+ ["parameters", "parameters"],
228
+ ["entities", "entities"],
229
+ ["rules", "rules"],
230
+ ["integrations", "integrations"],
231
+ ];
232
+ for (const [key, section] of arraySections) {
233
+ const items = response[key];
234
+ if (!Array.isArray(items))
131
235
  continue;
132
- if (def.type === "object" && !("additionalProperties" in def)) {
133
- def.additionalProperties = false;
236
+ const file = SECTION_TO_FILE[section];
237
+ for (const item of items) {
238
+ const pathPrefix = `${section}[${item.index}]`;
239
+ for (const err of item.result.errors ?? []) {
240
+ issues.push(backendIssueToValidationIssue({ ...err, severity: err.severity ?? "error" }, file, pathPrefix));
241
+ }
242
+ for (const w of item.result.warnings ?? []) {
243
+ issues.push(backendIssueToValidationIssue({ ...w, severity: w.severity ?? "warning" }, file, pathPrefix));
244
+ }
134
245
  }
135
246
  }
247
+ // ── agent_tools (one-file-per-tool) ──
248
+ const tools = response.agent_tools;
249
+ if (Array.isArray(tools)) {
250
+ for (const item of tools) {
251
+ const toolFile = payloadIndex.toolFiles[item.index] ?? `tools[${item.index}]`;
252
+ for (const err of item.result.errors ?? []) {
253
+ issues.push(backendIssueToValidationIssue({ ...err, severity: err.severity ?? "error" }, toolFile, "tool"));
254
+ }
255
+ for (const w of item.result.warnings ?? []) {
256
+ issues.push(backendIssueToValidationIssue({ ...w, severity: w.severity ?? "warning" }, toolFile, "tool"));
257
+ }
258
+ }
259
+ }
260
+ return issues;
136
261
  }
137
262
  /**
138
- * Recursively collect leaf Condition objects from a when clause (which may
139
- * contain nested all/any/not groups).
263
+ * Render results into the existing `ValidateView` envelope. We split
264
+ * issues into `schemaIssues` (issues with a specific path inside a
265
+ * section, i.e. backend schema validation) and `crossRefIssues` (no
266
+ * sub-path → cross-section references, the legacy local code's
267
+ * categorization). This keeps the UI grouping the user already knows.
140
268
  */
141
- function collectConditions(items, out) {
142
- for (const item of items) {
143
- if (!item || typeof item !== "object")
144
- continue;
145
- const obj = item;
146
- if (obj.method) {
147
- out.push(obj);
269
+ function categorizeIssues(issues) {
270
+ const schemaIssues = [];
271
+ const crossRefIssues = [];
272
+ for (const issue of issues) {
273
+ // Heuristic: backend issues with a non-trivial path go into "schema"
274
+ // (validator caught a specific field). Issues whose path is the bare
275
+ // section name represent cross-references the validator surfaces as
276
+ // "missing reference foo" / "duplicate code bar" — group them as
277
+ // cross-reference issues so the user sees the same two-section UI
278
+ // they had with local validation.
279
+ const path = issue.path ?? "";
280
+ if (path.includes(".") || path.includes("[")) {
281
+ schemaIssues.push(issue);
148
282
  }
149
- for (const key of ["all", "any", "not"]) {
150
- if (Array.isArray(obj[key])) {
151
- collectConditions(obj[key], out);
152
- }
283
+ else {
284
+ crossRefIssues.push(issue);
153
285
  }
154
286
  }
287
+ return { schemaIssues, crossRefIssues };
155
288
  }
156
289
  /**
157
- * Validate AUI configuration files against domain schemas
290
+ * Validate AUI configuration files against the agent-settings backend.
291
+ *
292
+ * Returns `true` only when the backend explicitly reports `valid: true`
293
+ * with no error-severity issues. Any other outcome (errors, network
294
+ * failure, missing config) returns `false` so `aui push` blocks the
295
+ * push unless the user passes `--force`.
158
296
  */
159
297
  export async function validate(targetPath, options = {}) {
160
298
  const tracer = getTracer();
161
299
  return tracer.startActiveSpan("aui.validate", async (span) => {
162
- setUserContext(span);
300
+ await setUserContext(span);
163
301
  try {
164
- const result = await _validate(targetPath, options);
165
- span.setStatus({ code: result ? SpanStatusCode.OK : SpanStatusCode.ERROR, message: result ? undefined : "Validation failed" });
302
+ const result = await _validate(span, targetPath, options);
303
+ span.setAttribute("validate.result.valid", result);
304
+ span.setStatus({
305
+ code: result ? SpanStatusCode.OK : SpanStatusCode.ERROR,
306
+ message: result ? undefined : "Validation failed",
307
+ });
166
308
  return result;
167
309
  }
168
310
  catch (error) {
@@ -178,773 +320,286 @@ export async function validate(targetPath, options = {}) {
178
320
  }
179
321
  });
180
322
  }
181
- async function _validate(targetPath, options = {}) {
323
+ async function _validate(parentSpan, targetPath, options = {}) {
182
324
  const projectRoot = targetPath || findProjectRoot() || process.cwd();
325
+ parentSpan.setAttribute("validate.project_root", projectRoot);
183
326
  if (targetPath && !fs.existsSync(targetPath)) {
184
327
  throw new ValidationError(`Path does not exist: ${targetPath}`, {
185
328
  suggestion: "Provide a valid directory path, or run from an AUI project directory.",
186
329
  });
187
330
  }
188
- // 1. Discover schemas
189
- const schemas = await discoverSchemas(projectRoot);
190
- const schemaNames = [...schemas.keys()];
191
- const issues = [];
192
- const validatedFiles = [];
193
- let totalFiles = 0;
194
- // 2. For each schema, validate matching files
195
- for (const [typeName, schemaPath] of schemas) {
196
- let schemaData;
197
- try {
198
- schemaData = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
199
- }
200
- catch (e) {
201
- issues.push({
202
- type: "error",
203
- file: path.basename(schemaPath),
204
- message: `Failed to parse schema: ${e instanceof Error ? e.message : e}`,
205
- });
206
- continue;
207
- }
208
- patchRecursiveWhenDefs(schemaData);
209
- const ajv = new Ajv({ allErrors: true, strict: false });
210
- let validateFn;
211
- try {
212
- validateFn = ajv.compile(schemaData);
213
- }
214
- catch (e) {
215
- issues.push({
216
- type: "error",
217
- file: path.basename(schemaPath),
218
- message: `Failed to compile schema: ${e instanceof Error ? e.message : e}`,
219
- });
220
- continue;
221
- }
222
- const files = await findFilesForType(projectRoot, typeName);
223
- if (files.length === 0) {
224
- continue;
225
- }
226
- const dataKey = TYPE_TO_KEY[typeName];
227
- const issueType = WARNING_ONLY_TYPES.has(typeName) ? "warning" : "error";
228
- for (const file of files) {
229
- totalFiles++;
230
- const relativePath = path.relative(projectRoot, file);
231
- validatedFiles.push(relativePath);
232
- const parsed = parseAuiFile(file);
233
- if (!parsed) {
234
- issues.push({
235
- type: "error",
236
- file: relativePath,
237
- message: "Invalid JSON syntax",
238
- });
239
- continue;
240
- }
241
- const dataToValidate = dataKey && parsed[dataKey] !== undefined ? parsed[dataKey] : parsed;
242
- // // Knowledge-hub kb.json files with an empty resources array are valid —
243
- // // they represent newly-created or cleared knowledge bases that haven't
244
- // // been populated yet. Skip schema validation for these.
245
- // if (
246
- // (typeName === "knowledge-bases" || typeName === "knowledge-bases-files") &&
247
- // Array.isArray((parsed as Record<string, unknown>).resources) &&
248
- // ((parsed as Record<string, unknown>).resources as unknown[]).length === 0
249
- // ) {
250
- // continue;
251
- // }
252
- if (Array.isArray(dataToValidate)) {
253
- for (let i = 0; i < dataToValidate.length; i++) {
254
- const valid = validateFn(dataToValidate[i]);
255
- if (!valid) {
256
- issues.push(...ajvErrorsToIssues(validateFn.errors, relativePath, issueType).map((issue) => ({
257
- ...issue,
258
- path: `${dataKey}[${i}]${issue.path === "/" ? "" : issue.path}`,
259
- })));
260
- }
261
- }
262
- }
263
- else {
264
- const valid = validateFn(dataToValidate);
265
- if (!valid) {
266
- issues.push(...ajvErrorsToIssues(validateFn.errors, relativePath, issueType));
267
- }
268
- }
269
- }
270
- }
271
- // Mark the boundary between schema and cross-reference issues
272
- const schemaIssueCount = issues.length;
273
- // 3. Cross-reference: ensure all params in integrations/entities exist in parameters
274
- const agentData = await readAgentFiles(projectRoot);
275
- // 3a. Rules: if a condition has more than 1 param, operation must be any_has_value or all_has_value
276
- const MULTI_PARAM_OPERATIONS = ["any_has_value", "all_has_value"];
277
- const rulesRaw = agentData.rules;
278
- const rulesList = Array.isArray(rulesRaw)
279
- ? rulesRaw
280
- : [];
281
- for (const rule of rulesList) {
282
- const conditions = [];
283
- const when = rule.when;
284
- if (when) {
285
- for (const key of ["all", "any", "not"]) {
286
- const items = when[key];
287
- if (Array.isArray(items)) {
288
- collectConditions(items, conditions);
289
- }
290
- }
291
- }
292
- for (const cond of conditions) {
293
- const params = cond.params;
294
- const operation = cond.operation;
295
- if (params && params.length > 1) {
296
- if (operation && !MULTI_PARAM_OPERATIONS.includes(operation)) {
297
- issues.push({
298
- type: "error",
299
- file: "rules.aui.json",
300
- message: `Rule "${rule.code}" has a condition with ${params.length} params [${params.join(", ")}] but operation "${operation}" — when params has more than 1 value, operation must be one of: ${MULTI_PARAM_OPERATIONS.join(", ")}`,
301
- });
302
- }
303
- if (cond.value !== null && cond.value !== undefined) {
304
- issues.push({
305
- type: "error",
306
- file: "rules.aui.json",
307
- message: `Rule "${rule.code}" has a condition with ${params.length} params [${params.join(", ")}] but value is "${cond.value}" — when params has more than 1 value, value must be null`,
308
- });
309
- }
310
- }
311
- }
312
- }
313
- // 3b. Tool rules: when with empty combinator (e.g. { all: [] }) should be null
314
- const toolFiles = await findFilesForType(projectRoot, "tools");
315
- for (const file of toolFiles) {
316
- const parsed = parseAuiFile(file);
317
- if (!parsed?.tool)
318
- continue;
319
- const tool = parsed.tool;
320
- const relativePath = path.relative(projectRoot, file);
321
- for (const section of ["pre", "summary"]) {
322
- const ruleSection = tool.rules?.[section];
323
- if (!ruleSection)
324
- continue;
325
- for (const [category, ruleArray] of Object.entries(ruleSection)) {
326
- if (!Array.isArray(ruleArray))
327
- continue;
328
- for (const rule of ruleArray) {
329
- const r = rule;
330
- // when: null is only allowed in summary rules
331
- if (r.when === null && section !== "summary") {
332
- issues.push({
333
- type: "error",
334
- file: relativePath,
335
- message: `Tool "${tool.code}" has a ${section}.${category} rule with when: null — when: null is only allowed in summary rules. Non-summary rules must have a condition.`,
336
- });
337
- continue;
338
- }
339
- if (r.when && typeof r.when === "object") {
340
- const keys = Object.keys(r.when);
341
- const isEmpty = keys.length === 0 ||
342
- keys.every((k) => {
343
- const v = r.when[k];
344
- return Array.isArray(v) && v.length === 0;
345
- });
346
- if (isEmpty) {
347
- issues.push({
348
- type: "error",
349
- file: relativePath,
350
- message: `Tool "${tool.code}" has a ${section}.${category} rule with an empty "when" clause (e.g. { all: [] }) — ${section === "summary" ? "use when: null instead" : "non-summary rules must have a condition"}`,
351
- });
352
- }
353
- }
354
- }
355
- }
356
- }
357
- }
358
- // 3c. Immutable tool codes: certain tool codes must not be renamed
359
- for (const file of toolFiles) {
360
- const parsed = parseAuiFile(file);
361
- if (!parsed?.tool)
362
- continue;
363
- const tool = parsed.tool;
364
- const stem = path.basename(file, ".aui.json").toUpperCase();
365
- if (IMMUTABLE_TOOL_CODES.includes(stem) && tool.code !== stem) {
366
- issues.push({
367
- type: "error",
368
- file: path.relative(projectRoot, file),
369
- message: `Tool code "${tool.code}" was renamed from "${stem}" — this code is immutable and must not be changed`,
370
- });
371
- }
372
- }
373
- // ── 0. Duplicate-code rejection ─────────────────────────────────────────
374
- //
375
- // Each entity-array file (parameters, entities, integrations, rules) keys
376
- // its items by `code` (or `name` for entities). The agent-settings backend
377
- // treats each code as a unique platform-side ID. If the local file has two
378
- // items with the same code, the push layer's git-diff machinery synthesises
379
- // a `code[N]` suffix to disambiguate, and `_executePushTask` then issues
380
- // PATCH/POST against `parameters/view/code[1]` — which 404s because that
381
- // bracket-suffixed code doesn't exist on the platform. The user sees an
382
- // opaque "PATCH param user-id[1] failed: 404" with no clue why.
383
- //
384
- // Catching this at validate-time turns the opaque runtime failure into a
385
- // precise, actionable pre-push error pointing at the exact duplicate.
386
- // No data corruption risk if missed (push fails loudly, exit non-zero,
387
- // baseline correctly held back per Finding B), but the UX is significantly
388
- // better when surfaced here.
389
- const findDuplicates = (items, keyOf) => {
390
- const counts = new Map();
391
- for (const item of items ?? []) {
392
- const k = keyOf(item);
393
- if (!k)
394
- continue;
395
- counts.set(k, (counts.get(k) ?? 0) + 1);
396
- }
397
- // Keep only keys that appear more than once.
398
- for (const [k, n] of [...counts.entries()]) {
399
- if (n < 2)
400
- counts.delete(k);
401
- }
402
- return counts;
403
- };
404
- const dupParams = findDuplicates(agentData.parameters, (p) => p.code);
405
- for (const [code, n] of dupParams) {
406
- issues.push({
407
- type: "error",
408
- file: "parameters.aui.json",
409
- message: `Duplicate parameter code "${code}" appears ${n} times — codes must be unique within a file. The push layer can't disambiguate duplicates and would fail with an opaque 404.`,
331
+ // ── Resolve agent + auth context (required for remote validation) ──
332
+ const projectConfig = loadProjectConfig(projectRoot);
333
+ const session = loadSession();
334
+ const config = getConfig(projectRoot);
335
+ const agentId = projectConfig?.agent_id || session?.network_id;
336
+ const accountId = projectConfig?.account_id || config.accountId;
337
+ const organizationId = projectConfig?.organization_id || config.organizationId;
338
+ const authToken = config.authToken;
339
+ // Attach agent-scoped identifiers to the parent span as early as
340
+ // possible — even if validation fails further down, Logfire shows the
341
+ // right "which agent/account/org" row.
342
+ await setAgentContext(parentSpan, {
343
+ agentId: agentId ?? undefined,
344
+ agentCode: projectConfig?.agent_code ?? undefined,
345
+ agentManagementId: projectConfig?.agent_management_id ?? undefined,
346
+ versionId: projectConfig?.version_id ?? undefined,
347
+ versionLabel: projectConfig?.version_label ?? undefined,
348
+ networkId: agentId ?? undefined,
349
+ accountId: accountId ?? undefined,
350
+ organizationId: organizationId ?? undefined,
351
+ networkCategoryId: projectConfig?.network_category_id ?? undefined,
352
+ });
353
+ parentSpan.setAttribute("validate.has_project_config", !!projectConfig);
354
+ parentSpan.setAttribute("validate.has_session", !!session);
355
+ parentSpan.setAttribute("validate.environment", config.environment);
356
+ // ── Build the validate payload from local files ──
357
+ const { payload, index, parseErrors } = await buildValidatePayload(projectRoot);
358
+ parentSpan.setAttribute("validate.payload.general_settings", !!payload.general_settings);
359
+ parentSpan.setAttribute("validate.payload.parameters_count", payload.parameters?.length ?? 0);
360
+ parentSpan.setAttribute("validate.payload.entities_count", payload.entities?.length ?? 0);
361
+ parentSpan.setAttribute("validate.payload.rules_count", payload.rules?.length ?? 0);
362
+ parentSpan.setAttribute("validate.payload.integrations_count", payload.integrations?.length ?? 0);
363
+ parentSpan.setAttribute("validate.payload.tools_count", payload.tools?.length ?? 0);
364
+ parentSpan.setAttribute("validate.total_files", index.totalFiles);
365
+ parentSpan.setAttribute("validate.parse_errors", parseErrors.length);
366
+ // ── Early-exit: nothing to validate ──
367
+ // An empty directory (or one without any `.aui.json` files) is a no-op
368
+ // for validate — match the pre-remote-validate behaviour where running
369
+ // `aui validate` somewhere with nothing to check just printed a clean
370
+ // "no files" summary and exited 0. Without this guard the remote
371
+ // pre-check below would error out (no agent_id / no auth) and break
372
+ // the long-standing UX for the empty-dir smoke test in particular.
373
+ if (index.totalFiles === 0 &&
374
+ parseErrors.length === 0 &&
375
+ !payload.general_settings &&
376
+ !payload.parameters &&
377
+ !payload.entities &&
378
+ !payload.rules &&
379
+ !payload.integrations &&
380
+ !payload.tools) {
381
+ parentSpan.addEvent("validate.no_files_to_validate");
382
+ return renderAndReturn({
383
+ projectRoot,
384
+ issues: [],
385
+ validatedFiles: [],
386
+ totalFiles: 0,
387
+ verbose: options.verbose ?? false,
388
+ remoteOk: true,
410
389
  });
411
390
  }
412
- const dupEntities = findDuplicates(agentData.entities, (e) => e.name);
413
- for (const [name, n] of dupEntities) {
414
- issues.push({
415
- type: "error",
416
- file: "entities.aui.json",
417
- message: `Duplicate entity name "${name}" appears ${n} times — names must be unique within entities.aui.json.`,
391
+ // ── Decide if we can call the remote validator ──
392
+ // A remote call needs (1) an agent id to address the right validate
393
+ // endpoint, (2) an auth token, and (3) account+org for the headers
394
+ // the agent-settings gateway expects. Anything missing → we surface a
395
+ // clear error rather than silently passing.
396
+ const missing = [];
397
+ if (!agentId)
398
+ missing.push("agent_id (.auirc or session)");
399
+ if (!authToken)
400
+ missing.push("auth token (run `aui login`)");
401
+ if (!accountId)
402
+ missing.push("account_id");
403
+ if (!organizationId)
404
+ missing.push("organization_id");
405
+ // If we have JSON-syntax parse errors there is no point calling the
406
+ // remote validator — it can't even read the payload. Render those
407
+ // and exit early with `valid = false`.
408
+ if (parseErrors.length > 0) {
409
+ parentSpan.addEvent("validate.skipped_remote_due_to_parse_errors", {
410
+ count: parseErrors.length,
411
+ });
412
+ return renderAndReturn({
413
+ projectRoot,
414
+ issues: parseErrors,
415
+ validatedFiles: index.validatedFiles,
416
+ totalFiles: index.totalFiles,
417
+ verbose: options.verbose ?? false,
418
+ remoteOk: false,
418
419
  });
419
420
  }
420
- const dupIntegrations = findDuplicates(agentData.integrations, (i) => i.code);
421
- for (const [code, n] of dupIntegrations) {
422
- issues.push({
421
+ if (missing.length > 0) {
422
+ parentSpan.addEvent("validate.missing_remote_context", {
423
+ missing: missing.join(", "),
424
+ });
425
+ const issue = {
423
426
  type: "error",
424
- file: "integrations.aui.json",
425
- message: `Duplicate integration code "${code}" appears ${n} times — codes must be unique within integrations.aui.json.`,
427
+ file: ".auirc",
428
+ message: `Cannot run remote validation — missing: ${missing.join(", ")}. ` +
429
+ "Run `aui import-agent` to link an agent and `aui login` to authenticate.",
430
+ };
431
+ return renderAndReturn({
432
+ projectRoot,
433
+ issues: [issue],
434
+ validatedFiles: index.validatedFiles,
435
+ totalFiles: index.totalFiles,
436
+ verbose: options.verbose ?? false,
437
+ remoteOk: false,
426
438
  });
427
439
  }
428
- // Top-level rules.aui.json (constraint rules with `code` field).
429
- const topLevelRules = agentData.rules;
430
- if (Array.isArray(topLevelRules)) {
431
- const dupRules = findDuplicates(topLevelRules, (r) => r.code);
432
- for (const [code, n] of dupRules) {
433
- issues.push({
434
- type: "error",
435
- file: "rules.aui.json",
436
- message: `Duplicate rule code "${code}" appears ${n} times — rule codes must be unique within rules.aui.json.`,
437
- });
438
- }
439
- }
440
- // Tool-internal summary rule arrays (success / fail / missing_info each
441
- // contain SummaryRule items with optional `code`). Duplicates here cause
442
- // the same diff-machinery confusion if the tool ever moves to per-rule
443
- // PATCH semantics, and they're a code-smell either way.
444
- for (const tool of agentData.tools || []) {
445
- for (const section of ["pre", "summary"]) {
446
- const ruleSection = tool.rules?.[section];
447
- if (!ruleSection)
448
- continue;
449
- for (const [category, ruleArray] of Object.entries(ruleSection)) {
450
- if (!Array.isArray(ruleArray))
451
- continue;
452
- const dupToolRules = findDuplicates(ruleArray, (r) => r.code);
453
- for (const [code, n] of dupToolRules) {
454
- issues.push({
455
- type: "error",
456
- file: `tools/${tool.code}.aui.json`,
457
- message: `Tool "${tool.code}" has duplicate ${section}.${category} rule code "${code}" appears ${n} times — rule codes must be unique within their array.`,
458
- });
459
- }
460
- }
461
- }
462
- }
463
- const paramCodes = new Set(agentData.parameters?.map((p) => p.code) || []);
464
- const integrationCodes = new Set(agentData.integrations?.map((i) => i.code) || []);
465
- const entityNames = new Set(agentData.entities?.map((e) => e.name) || []);
466
- const toolCodes = new Set(agentData.tools?.map((t) => t.code) || []);
467
- // ── 1. Tool → Integration references ──
468
- for (const tool of agentData.tools || []) {
469
- for (const ref of tool.integrations || []) {
470
- if (!integrationCodes.has(ref.code)) {
471
- issues.push({
472
- type: "error",
473
- file: `tools/${tool.code}.aui.json`,
474
- message: `Tool "${tool.code}" references integration "${ref.code}" which is not defined in integrations`,
475
- });
476
- }
477
- }
478
- }
479
- // ── 2. Tool → Parameter references (required/optional) ──
480
- for (const tool of agentData.tools || []) {
481
- if (tool.config?.params) {
482
- for (const req of tool.config.params.required || []) {
483
- const codes = Array.isArray(req) ? req : [req];
484
- for (const code of codes) {
485
- if (!paramCodes.has(code)) {
486
- issues.push({
487
- type: "error",
488
- file: `tools/${tool.code}.aui.json`,
489
- message: `Tool "${tool.code}" requires parameter "${code}" which is not defined in parameters`,
490
- });
491
- }
492
- }
493
- }
494
- for (const code of tool.config.params.optional || []) {
495
- if (!paramCodes.has(code)) {
496
- issues.push({
497
- type: "error",
498
- file: `tools/${tool.code}.aui.json`,
499
- message: `Tool "${tool.code}" references optional parameter "${code}" which is not defined in parameters`,
500
- });
501
- }
502
- }
503
- }
504
- }
505
- // ── 3. Tool → Entity references (available_context) ──
506
- for (const tool of agentData.tools || []) {
507
- if (tool.available_context?.entities) {
508
- for (const entityName of tool.available_context.entities) {
509
- if (!entityNames.has(entityName)) {
510
- issues.push({
511
- type: "error",
512
- file: `tools/${tool.code}.aui.json`,
513
- message: `Tool "${tool.code}" references entity "${entityName}" in available_context which is not defined in entities`,
514
- });
515
- }
516
- }
517
- }
518
- }
519
- // ── 4. Tool rules → Parameter references (pre/summary then.params) ──
520
- for (const tool of agentData.tools || []) {
521
- for (const section of ["pre", "summary"]) {
522
- const ruleSection = tool.rules?.[section];
523
- if (!ruleSection)
524
- continue;
525
- for (const [category, ruleArray] of Object.entries(ruleSection)) {
526
- if (!Array.isArray(ruleArray))
527
- continue;
528
- for (const rule of ruleArray) {
529
- const r = rule;
530
- if (r.then?.params?.mandatory) {
531
- for (const p of r.then.params.mandatory) {
532
- if (!paramCodes.has(p)) {
533
- issues.push({
534
- type: "error",
535
- file: `tools/${tool.code}.aui.json`,
536
- message: `Tool "${tool.code}" rule ${section}.${category} references mandatory parameter "${p}" which is not defined in parameters`,
537
- });
538
- }
539
- }
540
- }
541
- if (r.then?.params?.optional) {
542
- for (const p of r.then.params.optional) {
543
- if (!paramCodes.has(p)) {
544
- issues.push({
545
- type: "error",
546
- file: `tools/${tool.code}.aui.json`,
547
- message: `Tool "${tool.code}" rule ${section}.${category} references optional parameter "${p}" which is not defined in parameters`,
548
- });
549
- }
550
- }
551
- }
552
- }
553
- }
554
- }
555
- }
556
- // ── 5. Global rules → Tool references (applies_to) ──
557
- for (const rule of rulesList) {
558
- const appliesTo = rule.applies_to;
559
- if (appliesTo) {
560
- for (const toolCode of appliesTo) {
561
- if (!toolCodes.has(toolCode)) {
562
- issues.push({
563
- type: "error",
564
- file: "rules.aui.json",
565
- message: `Rule "${rule.code}" applies_to references tool "${toolCode}" which is not defined in tools`,
566
- });
567
- }
568
- }
569
- }
570
- }
571
- // ── 6. Global rules → Parameter references (conditions + then.params) ──
572
- for (const rule of rulesList) {
573
- const ruleCode = (rule.code || rule.description || "unknown");
574
- const conditions = [];
575
- const when = rule.when;
576
- if (when) {
577
- for (const key of ["all", "any", "not"]) {
578
- const items = when[key];
579
- if (Array.isArray(items)) {
580
- collectConditions(items, conditions);
581
- }
582
- }
583
- }
584
- for (const cond of conditions) {
585
- const condParams = cond.params;
586
- if (condParams) {
587
- for (const p of condParams) {
588
- if (!paramCodes.has(p)) {
589
- issues.push({
590
- type: "error",
591
- file: "rules.aui.json",
592
- message: `Rule "${ruleCode}" condition references parameter "${p}" which is not defined in parameters`,
593
- });
594
- }
595
- }
596
- }
597
- }
598
- const then = rule.then;
599
- if (then?.params?.mandatory) {
600
- for (const p of then.params.mandatory) {
601
- if (!paramCodes.has(p)) {
602
- issues.push({
603
- type: "error",
604
- file: "rules.aui.json",
605
- message: `Rule "${ruleCode}" then.params.mandatory references parameter "${p}" which is not defined in parameters`,
606
- });
607
- }
608
- }
609
- }
610
- if (then?.params?.optional) {
611
- for (const p of then.params.optional) {
612
- if (!paramCodes.has(p)) {
613
- issues.push({
614
- type: "error",
615
- file: "rules.aui.json",
616
- message: `Rule "${ruleCode}" then.params.optional references parameter "${p}" which is not defined in parameters`,
617
- });
618
- }
619
- }
620
- }
621
- }
622
- // ── Existing validations ──
623
- const responseMappingParams = new Set();
624
- const requestSchemaParams = new Set();
625
- for (const integration of agentData.integrations || []) {
626
- const settings = integration.settings;
627
- if (!settings)
628
- continue;
629
- if (settings.request_schema) {
630
- for (const field of settings.request_schema) {
631
- for (const p of field.params || []) {
632
- requestSchemaParams.add(p);
633
- }
634
- }
635
- }
636
- if (settings.response_mapping) {
637
- for (const groups of Object.values(settings.response_mapping)) {
638
- for (const group of groups) {
639
- for (const mapping of group.mappings || []) {
640
- if (mapping.param) {
641
- responseMappingParams.add(mapping.param);
642
- }
643
- }
644
- }
645
- }
646
- }
647
- }
648
- // Validate integration code matches kebab-case transformation of name
649
- for (const integration of agentData.integrations || []) {
650
- if (integration.name) {
651
- const expectedCode = integration.name
652
- .toLowerCase()
653
- .replace(/[\s_]+/g, "-");
654
- if (integration.code !== expectedCode) {
655
- issues.push({
656
- type: "error",
657
- file: "integrations.aui.json",
658
- message: `Integration "${integration.name}" has code "${integration.code}" but expected "${expectedCode}" (name lowercased with spaces and underscores replaced by dashes)`,
659
- });
440
+ // ── Call remote validation ──
441
+ const client = new AUIClient({
442
+ baseUrl: config.apiUrl,
443
+ authToken: authToken,
444
+ accountId: accountId,
445
+ organizationId: organizationId,
446
+ environment: config.environment,
447
+ });
448
+ const tracer = getTracer();
449
+ let response = null;
450
+ let remoteError = null;
451
+ await tracer.startActiveSpan("aui.validate.remote", async (rSpan) => {
452
+ rSpan.setAttribute("validate.agent_id", agentId);
453
+ rSpan.setAttribute("validate.account_id", accountId);
454
+ rSpan.setAttribute("validate.organization_id", organizationId);
455
+ rSpan.setAttribute("validate.environment", config.environment);
456
+ rSpan.setAttribute("validate.payload.tools_count", payload.tools?.length ?? 0);
457
+ rSpan.setAttribute("validate.payload.parameters_count", payload.parameters?.length ?? 0);
458
+ rSpan.setAttribute("validate.payload.entities_count", payload.entities?.length ?? 0);
459
+ const startedAt = Date.now();
460
+ try {
461
+ response = await client.agentManagement.validateAgent(agentId, payload);
462
+ rSpan.setAttribute("validate.remote.duration_ms", Date.now() - startedAt);
463
+ rSpan.setAttribute("validate.remote.valid", response.valid);
464
+ const errorCount = countIssues(response, "errors");
465
+ const warningCount = countIssues(response, "warnings");
466
+ rSpan.setAttribute("validate.remote.error_count", errorCount);
467
+ rSpan.setAttribute("validate.remote.warning_count", warningCount);
468
+ rSpan.setStatus({ code: SpanStatusCode.OK });
469
+ }
470
+ catch (err) {
471
+ const msg = err instanceof Error ? err.message : String(err);
472
+ remoteError = msg;
473
+ rSpan.setAttribute("validate.remote.duration_ms", Date.now() - startedAt);
474
+ if (err instanceof AUIAPIError) {
475
+ rSpan.setAttribute("http.status_code", err.status);
476
+ rSpan.setAttribute("validate.remote.error_body", typeof err.body === "string" ? err.body : JSON.stringify(err.body ?? null));
660
477
  }
478
+ rSpan.setStatus({ code: SpanStatusCode.ERROR, message: msg });
479
+ rSpan.recordException(err instanceof Error ? err : new Error(msg));
661
480
  }
662
- }
663
- // ── API integrations must have non-empty response mappings ──
664
- for (const integration of agentData.integrations || []) {
665
- if (integration.type !== "API" || integration.code === "web-search")
666
- continue;
667
- const settings = integration.settings;
668
- if (!settings)
669
- continue;
670
- const mapping = settings.response_mapping;
671
- const hasMapping = mapping &&
672
- typeof mapping === "object" &&
673
- Object.values(mapping).some((groups) => Array.isArray(groups) &&
674
- groups.some((g) => Array.isArray(g.mappings) && g.mappings.length > 0));
675
- if (!hasMapping) {
676
- issues.push({
677
- type: "error",
678
- file: "integrations.aui.json",
679
- message: `Integration "${integration.code}" has empty response mappings — API integrations must have at least one response mapping`,
680
- });
481
+ finally {
482
+ rSpan.end();
681
483
  }
682
- }
683
- // ── test_curl validation: only for added/modified API integrations ──
684
- // These are warnings by default, errors only with --strict
685
- const curlIssueType = options.strict ? "error" : "warning";
686
- // Load the last-committed version of integrations to detect changes
687
- let committedIntegrations = new Map();
688
- try {
689
- const committedRaw = execSync("git show HEAD:integrations.aui.json", {
690
- encoding: "utf-8",
691
- cwd: projectRoot,
692
- stdio: ["pipe", "pipe", "pipe"],
484
+ });
485
+ if (!response) {
486
+ // Remote validation could not be performed. We deliberately treat
487
+ // this as a hard failure (return false) rather than silently passing
488
+ // letting `aui push` proceed against an un-validated bundle would
489
+ // re-introduce exactly the class of bugs the duplicate-code rejection
490
+ // and other validate-time checks were added to prevent.
491
+ parentSpan.setAttribute("validate.remote.failure", remoteError ?? "unknown");
492
+ const friendly = formatRemoteFailure(remoteError);
493
+ const issue = {
494
+ type: "error",
495
+ file: "(remote validate)",
496
+ message: friendly,
497
+ };
498
+ return renderAndReturn({
499
+ projectRoot,
500
+ issues: [issue],
501
+ validatedFiles: index.validatedFiles,
502
+ totalFiles: index.totalFiles,
503
+ verbose: options.verbose ?? false,
504
+ remoteOk: false,
693
505
  });
694
- const committedData = JSON.parse(committedRaw);
695
- for (const int of committedData.integrations || []) {
696
- committedIntegrations.set(int.code, JSON.stringify(int));
697
- }
698
506
  }
699
- catch {
700
- // No committed version — all integrations are considered new
507
+ const responseLocal = response;
508
+ const remoteIssues = mapResponseToIssues(responseLocal, index);
509
+ const allIssues = [...parseErrors, ...remoteIssues];
510
+ parentSpan.setAttribute("validate.result.error_count", allIssues.filter((i) => i.type === "error").length);
511
+ parentSpan.setAttribute("validate.result.warning_count", allIssues.filter((i) => i.type === "warning").length);
512
+ return renderAndReturn({
513
+ projectRoot,
514
+ issues: allIssues,
515
+ validatedFiles: index.validatedFiles,
516
+ totalFiles: index.totalFiles,
517
+ verbose: options.verbose ?? false,
518
+ remoteOk: responseLocal.valid && allIssues.every((i) => i.type !== "error"),
519
+ });
520
+ }
521
+ /** Count issues of a given kind across all sections in the response. */
522
+ function countIssues(response, kind) {
523
+ let count = 0;
524
+ const gs = response.general_settings;
525
+ if (gs)
526
+ count += (gs[kind] ?? []).length;
527
+ const arr = (section) => {
528
+ if (!section)
529
+ return 0;
530
+ return section.reduce((s, it) => s + (it.result[kind] ?? []).length, 0);
531
+ };
532
+ count += arr(response.parameters);
533
+ count += arr(response.entities);
534
+ count += arr(response.rules);
535
+ count += arr(response.integrations);
536
+ count += arr(response.agent_tools);
537
+ return count;
538
+ }
539
+ /**
540
+ * Build a user-facing message for a remote-validation infra failure so
541
+ * `aui push` (and a direct `aui validate` call) tells the user exactly
542
+ * which knob to turn — instead of a raw `validate agent: 502 ...` blob.
543
+ */
544
+ function formatRemoteFailure(err) {
545
+ const base = err ?? "remote validation failed for an unknown reason";
546
+ // The error strings from AUIAPIError always start with "API Error <code>:"
547
+ // and writeV2-style errors say "<label> failed: <status> ...". Anchor
548
+ // status-code matching to those prefixes so an agent_id substring like
549
+ // "593" in the URL never gets misclassified as a 5xx response.
550
+ const statusMatch = base.match(/API Error (\d{3})/) || base.match(/failed: (\d{3})/);
551
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : null;
552
+ if (/timed out/i.test(base) || /timeout/i.test(base)) {
553
+ return `Remote validation timed out: ${base}. Retry, or use --force on \`aui push\` to bypass validation temporarily.`;
701
554
  }
702
- for (const integration of agentData.integrations || []) {
703
- if (integration.type !== "API" || integration.code === "web-search")
704
- continue;
705
- const settings = integration.settings;
706
- if (!settings)
707
- continue;
708
- // Skip unchanged integrations
709
- const currentStr = JSON.stringify(integration);
710
- const committedStr = committedIntegrations.get(integration.code);
711
- if (committedStr === currentStr)
712
- continue;
713
- // test_curl must not be nullish
714
- if (!settings.test_curl) {
715
- issues.push({
716
- type: curlIssueType,
717
- file: "integrations.aui.json",
718
- message: `Integration "${integration.code}" is missing test_curl — API integrations must have a test_curl command`,
719
- });
720
- continue;
721
- }
722
- // test_curl must be a valid curl command
723
- const trimmedCurl = settings.test_curl.trim();
724
- if (!trimmedCurl.startsWith("curl ") && trimmedCurl !== "curl") {
725
- issues.push({
726
- type: curlIssueType,
727
- file: "integrations.aui.json",
728
- message: `Integration "${integration.code}" test_curl must be a valid curl command (must start with "curl")`,
729
- });
730
- continue;
731
- }
732
- // test_curl must include the endpoint URL
733
- if (settings.endpoint?.url && !trimmedCurl.includes(settings.endpoint.url)) {
734
- issues.push({
735
- type: curlIssueType,
736
- file: "integrations.aui.json",
737
- message: `Integration "${integration.code}" test_curl does not include the endpoint URL "${settings.endpoint.url}" — the test_curl should use the same URL as the endpoint`,
738
- });
739
- }
740
- // Execute the test_curl command
741
- let curlOutput;
742
- try {
743
- curlOutput = execSync(settings.test_curl, {
744
- encoding: "utf-8",
745
- timeout: 30_000,
746
- stdio: ["pipe", "pipe", "pipe"],
747
- }).trim();
748
- }
749
- catch (e) {
750
- issues.push({
751
- type: curlIssueType,
752
- file: "integrations.aui.json",
753
- message: `Integration "${integration.code}" test_curl failed to execute: ${e instanceof Error ? e.message : e}`,
754
- });
755
- continue;
756
- }
757
- // Parse the curl response
758
- let curlResponse;
759
- try {
760
- curlResponse = JSON.parse(curlOutput);
761
- }
762
- catch {
763
- issues.push({
764
- type: curlIssueType,
765
- file: "integrations.aui.json",
766
- message: `Integration "${integration.code}" test_curl response is not valid JSON`,
767
- });
768
- continue;
769
- }
770
- // test_curl_response must exist
771
- if (settings.test_curl_response === undefined || settings.test_curl_response === null) {
772
- issues.push({
773
- type: curlIssueType,
774
- file: "integrations.aui.json",
775
- message: `Integration "${integration.code}" is missing test_curl_response — API integrations must have a test_curl_response to validate against`,
776
- });
777
- continue;
778
- }
779
- // Parse test_curl_response if it's a string
780
- let testCurlResponse = settings.test_curl_response;
781
- if (typeof testCurlResponse === "string") {
782
- try {
783
- testCurlResponse = JSON.parse(testCurlResponse);
784
- }
785
- catch {
786
- issues.push({
787
- type: curlIssueType,
788
- file: "integrations.aui.json",
789
- message: `Integration "${integration.code}" test_curl_response is a string but not valid JSON`,
790
- });
791
- continue;
792
- }
793
- }
794
- // Compare test_curl_response schema (keys) and values separately
795
- const collectKeysDeep = (obj, prefix = "") => {
796
- const keys = new Set();
797
- if (Array.isArray(obj)) {
798
- keys.add(prefix + "[]");
799
- if (obj.length > 0) {
800
- for (const k of collectKeysDeep(obj[0], prefix + "[]."))
801
- keys.add(k);
802
- }
803
- }
804
- else if (obj && typeof obj === "object") {
805
- for (const [k, v] of Object.entries(obj)) {
806
- const fullKey = prefix + k;
807
- keys.add(fullKey);
808
- if (v && typeof v === "object") {
809
- for (const nested of collectKeysDeep(v, fullKey + "."))
810
- keys.add(nested);
811
- }
812
- }
813
- }
814
- return keys;
815
- };
816
- const expectedKeys = collectKeysDeep(testCurlResponse);
817
- const actualKeys = collectKeysDeep(curlResponse);
818
- const missingKeys = [...expectedKeys].filter((k) => !actualKeys.has(k));
819
- const extraKeys = [...actualKeys].filter((k) => !expectedKeys.has(k));
820
- if (missingKeys.length > 0 || extraKeys.length > 0) {
821
- // Keys/schema are different — error
822
- const parts = [];
823
- if (missingKeys.length > 0)
824
- parts.push(`missing keys: ${missingKeys.join(", ")}`);
825
- if (extraKeys.length > 0)
826
- parts.push(`unexpected keys: ${extraKeys.join(", ")}`);
827
- issues.push({
828
- type: curlIssueType,
829
- file: "integrations.aui.json",
830
- message: `Integration "${integration.code}" test_curl response schema differs from test_curl_response — ${parts.join("; ")}`,
831
- });
832
- }
833
- else {
834
- // Keys match but values might differ — warning
835
- const expectedStr = JSON.stringify(testCurlResponse);
836
- const actualStr = JSON.stringify(curlResponse);
837
- if (expectedStr !== actualStr) {
838
- issues.push({
839
- type: "warning",
840
- file: "integrations.aui.json",
841
- message: `Integration "${integration.code}" test_curl response values differ from test_curl_response (schema matches but values changed)`,
842
- });
843
- }
844
- }
845
- // Cross-reference: mapping keys must be actual keys in the curl response
846
- if (settings.response_mapping && curlResponse && typeof curlResponse === "object") {
847
- // Collect all keys from the curl response (recursively from first item if array)
848
- const collectKeys = (obj) => {
849
- const keys = new Set();
850
- if (Array.isArray(obj)) {
851
- if (obj.length > 0) {
852
- for (const k of collectKeys(obj[0]))
853
- keys.add(k);
854
- }
855
- }
856
- else if (obj && typeof obj === "object") {
857
- for (const [k, v] of Object.entries(obj)) {
858
- keys.add(k);
859
- if (v && typeof v === "object") {
860
- for (const nested of collectKeys(v))
861
- keys.add(nested);
862
- }
863
- }
864
- }
865
- return keys;
866
- };
867
- const responseKeys = collectKeys(curlResponse);
868
- for (const groups of Object.values(settings.response_mapping)) {
869
- for (const group of groups) {
870
- for (const mapping of group.mappings || []) {
871
- const mappingKeys = Array.isArray(mapping.key) ? mapping.key : [mapping.key];
872
- for (const k of mappingKeys) {
873
- if (!responseKeys.has(k)) {
874
- issues.push({
875
- type: curlIssueType,
876
- file: "integrations.aui.json",
877
- message: `Integration "${integration.code}" response mapping key "${k}" (param: "${mapping.param}") is not found in the actual curl response`,
878
- });
879
- }
880
- }
881
- }
882
- }
883
- }
884
- }
555
+ if (/econnrefused|enotfound|network/i.test(base)) {
556
+ return `Remote validation could not reach the backend: ${base}. Check connectivity; use --force on \`aui push\` if urgent.`;
885
557
  }
886
- const entityParams = new Set();
887
- for (const entity of agentData.entities || []) {
888
- for (const p of entity.parameters || []) {
889
- entityParams.add(p);
890
- }
558
+ if (status === 401 || /unauthor/i.test(base)) {
559
+ return `Remote validation rejected the request (auth): ${base}. Run \`aui login\` and try again.`;
891
560
  }
892
- for (const p of responseMappingParams) {
893
- if (!paramCodes.has(p)) {
894
- issues.push({
895
- type: "error",
896
- file: "integrations.aui.json",
897
- message: `Integration response mapping references parameter "${p}" which is not defined in parameters`,
898
- });
899
- }
561
+ if (status === 404) {
562
+ return `Remote validation endpoint returned 404: ${base}. Verify .auirc.agent_id matches an existing agent.`;
900
563
  }
901
- for (const p of requestSchemaParams) {
902
- if (!paramCodes.has(p)) {
903
- issues.push({
904
- type: "error",
905
- file: "integrations.aui.json",
906
- message: `Integration request schema references parameter "${p}" which is not defined in parameters`,
907
- });
908
- }
564
+ if (status === 422) {
565
+ return `Remote validator could not parse the payload: ${base}.`;
909
566
  }
910
- for (const p of entityParams) {
911
- if (!paramCodes.has(p)) {
912
- issues.push({
913
- type: "error",
914
- file: "entities.aui.json",
915
- message: `Entity references parameter "${p}" which is not defined in parameters`,
916
- });
917
- }
567
+ if (status !== null && status >= 500 && status < 600) {
568
+ return `Remote validation backend error (${status}): ${base}. Retry shortly; use --force on \`aui push\` if urgent.`;
918
569
  }
919
- // Build result and render
920
- const schemaIssues = issues.slice(0, schemaIssueCount);
921
- const crossRefIssues = issues.slice(schemaIssueCount);
570
+ return `Remote validation failed: ${base}.`;
571
+ }
572
+ function renderAndReturn(args) {
573
+ const { schemaIssues, crossRefIssues } = categorizeIssues(args.issues);
922
574
  const result = {
923
- projectRoot,
924
- schemaCount: schemas.size,
925
- schemaNames,
926
- totalFiles,
927
- validatedFiles: [...new Set(validatedFiles)],
575
+ projectRoot: args.projectRoot,
576
+ // schemaCount/schemaNames are legacy fields from local AJV validation.
577
+ // We keep the shape stable but report `1` to mean "the remote schema",
578
+ // so the UI's "0 schemas / no .dschema.json files" warning never fires.
579
+ schemaCount: 1,
580
+ schemaNames: ["remote:agent-settings/v1/validate"],
581
+ totalFiles: args.totalFiles,
582
+ validatedFiles: [...new Set(args.validatedFiles)],
928
583
  schemaIssues,
929
584
  crossRefIssues,
930
- verbose: options.verbose ?? false,
585
+ verbose: args.verbose,
931
586
  };
587
+ const allErrors = args.issues.filter((i) => i.type === "error");
932
588
  if (isJsonMode()) {
933
- const allErrors = issues.filter((i) => i.type === "error");
934
589
  outputJson({
935
- valid: allErrors.length === 0,
936
- project_root: projectRoot,
937
- schemas: schemaNames,
938
- total_files: totalFiles,
939
- validated_files: [...new Set(validatedFiles)],
590
+ valid: allErrors.length === 0 && args.remoteOk,
591
+ project_root: args.projectRoot,
592
+ remote_ok: args.remoteOk,
593
+ schemas: result.schemaNames,
594
+ total_files: args.totalFiles,
595
+ validated_files: result.validatedFiles,
940
596
  schema_issues: schemaIssues,
941
597
  cross_ref_issues: crossRefIssues,
942
598
  error_count: allErrors.length,
943
599
  });
944
- return allErrors.length === 0;
600
+ return allErrors.length === 0 && args.remoteOk;
945
601
  }
946
602
  render(_jsx(ValidateView, { data: result }));
947
- const allErrors = issues.filter((i) => i.type === "error");
948
- return allErrors.length === 0;
603
+ return allErrors.length === 0 && args.remoteOk;
949
604
  }
950
605
  //# sourceMappingURL=validate.js.map