aui-agent-builder 0.3.103 → 0.3.104

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.
Files changed (52) hide show
  1. package/dist/api-client/index.d.ts +304 -19
  2. package/dist/api-client/index.d.ts.map +1 -1
  3. package/dist/api-client/index.js +337 -69
  4. package/dist/api-client/index.js.map +1 -1
  5. package/dist/commands/agents.d.ts +55 -1
  6. package/dist/commands/agents.d.ts.map +1 -1
  7. package/dist/commands/agents.js +193 -50
  8. package/dist/commands/agents.js.map +1 -1
  9. package/dist/commands/import-agent.d.ts +23 -0
  10. package/dist/commands/import-agent.d.ts.map +1 -1
  11. package/dist/commands/import-agent.js +802 -151
  12. package/dist/commands/import-agent.js.map +1 -1
  13. package/dist/commands/legacy/push-records-mode.d.ts +166 -0
  14. package/dist/commands/legacy/push-records-mode.d.ts.map +1 -0
  15. package/dist/commands/legacy/push-records-mode.js +2536 -0
  16. package/dist/commands/legacy/push-records-mode.js.map +1 -0
  17. package/dist/commands/pull-agent.d.ts +8 -0
  18. package/dist/commands/pull-agent.d.ts.map +1 -1
  19. package/dist/commands/pull-agent.js +567 -126
  20. package/dist/commands/pull-agent.js.map +1 -1
  21. package/dist/commands/push.d.ts +78 -107
  22. package/dist/commands/push.d.ts.map +1 -1
  23. package/dist/commands/push.js +942 -1804
  24. package/dist/commands/push.js.map +1 -1
  25. package/dist/commands/util/agent-mode.d.ts +69 -0
  26. package/dist/commands/util/agent-mode.d.ts.map +1 -0
  27. package/dist/commands/util/agent-mode.js +101 -0
  28. package/dist/commands/util/agent-mode.js.map +1 -0
  29. package/dist/commands/validate.d.ts.map +1 -1
  30. package/dist/commands/validate.js +23 -7
  31. package/dist/commands/validate.js.map +1 -1
  32. package/dist/commands/version-snapshot.d.ts.map +1 -1
  33. package/dist/commands/version-snapshot.js +253 -49
  34. package/dist/commands/version-snapshot.js.map +1 -1
  35. package/dist/commands/version.d.ts +15 -1
  36. package/dist/commands/version.d.ts.map +1 -1
  37. package/dist/commands/version.js +102 -7
  38. package/dist/commands/version.js.map +1 -1
  39. package/dist/config/index.d.ts +16 -1
  40. package/dist/config/index.d.ts.map +1 -1
  41. package/dist/config/index.js.map +1 -1
  42. package/dist/index.js +20 -5
  43. package/dist/index.js.map +1 -1
  44. package/dist/ui/views/ImportAgentView.d.ts +15 -0
  45. package/dist/ui/views/ImportAgentView.d.ts.map +1 -1
  46. package/dist/ui/views/ImportAgentView.js +8 -3
  47. package/dist/ui/views/ImportAgentView.js.map +1 -1
  48. package/dist/utils/index.d.ts +80 -0
  49. package/dist/utils/index.d.ts.map +1 -1
  50. package/dist/utils/index.js +330 -0
  51. package/dist/utils/index.js.map +1 -1
  52. package/package.json +1 -1
@@ -3,20 +3,18 @@ import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { render } from "ink";
5
5
  import { Box, Text } from "ink";
6
- import inquirer from "inquirer";
7
6
  import { getConfig, loadProjectConfig, saveProjectConfig, loadAgentSettingsApiKey, saveAgentSettingsApiKey, } from "../config/index.js";
8
7
  import { getValidSession } from "../services/auth.service.js";
9
- import { AUIClient, applyScopeLevel } from "../api-client/index.js";
10
- import { findAuiFiles, parseAuiFile } from "../utils/index.js";
11
- import { validate } from "./validate.js";
8
+ import { AUIClient, AUIAPIError } from "../api-client/index.js";
9
+ import { findAuiFiles, parseAuiFile, assembleBundleFromFiles, } from "../utils/index.js";
12
10
  import { getTracer, SpanStatusCode, setUserContext, setAgentContext, } from "../telemetry.js";
13
- import { trace } from "@opentelemetry/api";
14
- import { getItemLevelDiff } from "../utils/git.js";
15
- import { AuthenticationError, CLIError, ConfigError, ValidationError } from "../errors/index.js";
11
+ import { AuthenticationError, CLIError, ConfigError, ValidationError, } from "../errors/index.js";
16
12
  import { StatusLine, Spinner, ErrorDisplay, Hint, } from "../ui/components/index.js";
17
13
  import { colors, icons } from "../ui/theme.js";
18
- import { PushFileSummary, PushChangesView, PushTaskLine, PushFinalSummary, } from "../ui/views/PushView.js";
14
+ import { PushFileSummary, PushChangesView, PushFinalSummary, } from "../ui/views/PushView.js";
19
15
  import { isJsonMode, outputJson, stderrLog } from "../utils/json-output.js";
16
+ import { detectAgentBundleMode, isModeMismatchError, } from "./util/agent-mode.js";
17
+ import { runLegacyPush } from "./legacy/push-records-mode.js";
20
18
  // ─── Ink Rendering Helpers ───
21
19
  function log(node) {
22
20
  const { unmount } = render(node);
@@ -45,9 +43,7 @@ function startSpinner(label) {
45
43
  },
46
44
  /**
47
45
  * Internal: unconditionally unmount, no log line. Used by `withSpinner`
48
- * to guarantee the spinner stops even when the wrapped body throws an
49
- * exception that escapes the surrounding try/catch (which would
50
- * otherwise leave a phantom spinner spinning forever).
46
+ * to guarantee the spinner stops even when the wrapped body throws.
51
47
  */
52
48
  _forceUnmount() {
53
49
  safeUnmount();
@@ -56,12 +52,10 @@ function startSpinner(label) {
56
52
  }
57
53
  /**
58
54
  * Wrap a sync- or async-returning callback so the spinner ALWAYS unmounts,
59
- * even on uncaught exceptions. The callback can call `.succeed()` / `.fail()`
60
- * itself to render a final status line; otherwise the spinner just stops.
61
- *
62
- * This pattern eliminates the "phantom spinner" foot-gun where an exception
63
- * thrown between `startSpinner(...)` and `.succeed/.fail` leaves the Ink
64
- * render mounted forever — blocking the chat UI's "Still thinking…" state.
55
+ * even on uncaught exceptions. Eliminates the "phantom spinner" foot-gun
56
+ * where an exception between `startSpinner(...)` and `.succeed/.fail` leaves
57
+ * the Ink render mounted forever — blocking the chat UI's "Still thinking…"
58
+ * state until SIGTERM.
65
59
  */
66
60
  async function withSpinner(label, fn) {
67
61
  const spinner = startSpinner(label);
@@ -73,7 +67,7 @@ async function withSpinner(label, fn) {
73
67
  }
74
68
  }
75
69
  /**
76
- * Push local agent configuration to the backend
70
+ * Push local agent configuration to the backend.
77
71
  */
78
72
  export async function push(agentCode, options = {}) {
79
73
  const tracer = getTracer();
@@ -96,7 +90,7 @@ export async function push(agentCode, options = {}) {
96
90
  }
97
91
  });
98
92
  }
99
- async function _push(pushSpan, agentCode, options = {}) {
93
+ async function _push(pushSpan, _agentCode, options = {}) {
100
94
  const config = getConfig();
101
95
  const projectRoot = config.projectRoot || process.cwd();
102
96
  if (!config.isAuthenticated) {
@@ -120,201 +114,71 @@ async function _push(pushSpan, agentCode, options = {}) {
120
114
  });
121
115
  }
122
116
  const json = isJsonMode();
123
- // Record up-front whether the user invoked --force. We tag this on the
124
- // root push span (not just the preflight span) so dashboards / alerts
125
- // that look at the top of a trace can immediately see "this push used
126
- // --force" without drilling into preflight. Keeps the audit trail
127
- // legible regardless of where the user is browsing in Logfire.
117
+ // ─── Back-compat: --force / --skip-validation are no-ops ───
118
+ // The new push endpoint runs the v1 transcoder bundle-validate as the
119
+ // first thing it does (before the per-version Redis lock and any GCS
120
+ // write). We keep the flags so existing scripts still parse, and
121
+ // record on the span whether they were requested for traceability.
128
122
  pushSpan.setAttribute("push.force_used", options.force === true);
129
- if (!options.skipValidation) {
130
- if (!json)
131
- log(_jsx(StatusLine, { kind: "info", label: "Validating configuration..." }));
132
- else
133
- stderrLog("Validating configuration...");
134
- // Wrap the validate call in its own span so a "stuck at validate"
135
- // hang shows up clearly in Logfire as `aui.push.preflight.validate`
136
- // with status = unset (still running) — instead of the parent
137
- // `aui.push` span just sitting there with no clue why.
138
- const validateTracer = getTracer();
139
- // Captured via the `onResult` callback below. Held in a ref so it's
140
- // available outside the closure where the bypass event fires. The
141
- // explicit `ValidateResultSummary | null` type is necessary —
142
- // TypeScript's strict control-flow analysis would otherwise narrow
143
- // the declared type to `null` after the await, since the assignment
144
- // happens inside a callback whose execution it can't trace.
145
- let validateSummary = null;
146
- const valid = await validateTracer.startActiveSpan("aui.push.preflight.validate", async (vSpan) => {
147
- vSpan.setAttribute("push.preflight.step", "validate");
148
- vSpan.setAttribute("push.preflight.skipValidation", false);
149
- vSpan.setAttribute("push.preflight.force", options.force === true);
150
- // Validation now hits the agent-settings backend (see
151
- // commands/validate.tsx). Surface the agent identifiers on this
152
- // span so a Logfire row tells the on-call exactly which agent
153
- // was being validated when the preflight failed — without
154
- // having to drill into the nested `aui.validate.remote` child.
155
- await setUserContext(vSpan);
156
- await setAgentContext(vSpan, {
157
- agentId: projectConfig.agent_id,
158
- agentCode: projectConfig.agent_code,
159
- agentManagementId: projectConfig.agent_management_id,
160
- versionId: projectConfig.version_id,
161
- versionLabel: projectConfig.version_label,
162
- networkId: projectConfig.agent_id,
163
- accountId: projectConfig.account_id,
164
- organizationId: projectConfig.organization_id,
165
- networkCategoryId: projectConfig.network_category_id,
166
- });
167
- try {
168
- const ok = await validate(projectRoot, {
169
- verbose: false,
170
- onResult: (s) => {
171
- validateSummary = s;
172
- // Denormalize the validate result onto the preflight span
173
- // so the trace's top-level shows the verdict + counts
174
- // without having to expand the nested `aui.validate`
175
- // child. Especially valuable when --force later
176
- // bypasses: the bypass event reads from these.
177
- vSpan.setAttribute("push.preflight.validate.ok", s.ok);
178
- vSpan.setAttribute("push.preflight.validate.error_count", s.errorCount);
179
- vSpan.setAttribute("push.preflight.validate.warning_count", s.warningCount);
180
- vSpan.setAttribute("push.preflight.validate.remote_reachable", s.remoteReachable);
181
- if (s.remoteValid !== null) {
182
- vSpan.setAttribute("push.preflight.validate.remote_valid", s.remoteValid);
183
- }
184
- if (s.remoteStatusCode !== null) {
185
- vSpan.setAttribute("push.preflight.validate.remote_status_code", s.remoteStatusCode);
186
- }
187
- if (s.remoteFailureReason) {
188
- vSpan.setAttribute("push.preflight.validate.remote_failure_reason", s.remoteFailureReason);
189
- }
190
- if (s.issueCodes.length > 0) {
191
- vSpan.setAttribute("push.preflight.validate.issue_codes", s.issueCodes.join(","));
192
- }
193
- if (s.topIssueMessages.length > 0) {
194
- vSpan.setAttribute("push.preflight.validate.top_messages", JSON.stringify(s.topIssueMessages));
195
- }
196
- },
197
- });
198
- vSpan.setAttribute("push.preflight.validate.ok", ok);
199
- vSpan.setStatus({ code: SpanStatusCode.OK });
200
- return ok;
201
- }
202
- catch (err) {
203
- // validate() shouldn't throw under normal conditions, but if a
204
- // schema fetch or git call inside it does, surface it here so
205
- // we don't lose the error to the parent span's generic handler.
206
- const msg = err instanceof Error ? err.message : String(err);
207
- vSpan.setStatus({ code: SpanStatusCode.ERROR, message: msg });
208
- vSpan.recordException(err instanceof Error ? err : new Error(msg));
209
- throw err;
210
- }
211
- finally {
212
- vSpan.end();
213
- }
123
+ pushSpan.setAttribute("push.skip_validation_flag_used", options.skipValidation === true);
124
+ if (options.force || options.skipValidation) {
125
+ pushSpan.addEvent("preflight.legacy_flag_noop", {
126
+ force: options.force === true,
127
+ skip_validation: options.skipValidation === true,
214
128
  });
215
- // ── Safety gate: a falsy validate() result MUST block the push ──
216
- //
217
- // The remote validator may return false because:
218
- // • the backend reported `valid: false` (real validation errors)
219
- // • the backend was unreachable / 5xx (infra failure)
220
- // • the local project lacks an agent_id or auth (cannot run validate)
221
- // In every case we refuse to push so the user can fix the underlying
222
- // issue. `--force` remains the explicit escape hatch for emergencies
223
- // (matches the pre-remote-validate contract documented in this file).
224
- if (!valid && !options.force) {
225
- pushSpan.setAttribute("push.exit_reason", "validation_failed");
226
- pushSpan.addEvent("preflight.validation_rejected_push");
227
- throw new ValidationError("Push aborted due to validation errors.", {
228
- suggestion: "Fix the errors above, or use --force to push anyway.",
229
- });
129
+ if (!json) {
130
+ log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "muted", label: `${options.force ? "--force" : "--skip-validation"} is a no-op under the new push endpoint (server validates the bundle).` }) }));
230
131
  }
231
- if (!valid && options.force) {
232
- // Loud-log a --force bypass. The event carries the same data we
233
- // attached to the preflight span (error count, codes, top
234
- // messages, remote reachability) so anyone reading the trace —
235
- // or filtering Logfire for `event.name = 'preflight.validation_failed_but_forced'`
236
- // — sees WHY validation failed and what was bypassed without
237
- // needing to JOIN against nested spans. This is the closest we
238
- // get to an audit log of "who pushed broken stuff and what was
239
- // broken about it" without changing user behaviour.
240
- //
241
- // Cast: TS narrows `validateSummary` to `null` after the await
242
- // because it can't see the callback set it (control-flow analysis
243
- // doesn't track mutation through closures). Cast back to the
244
- // declared union — the runtime value really is `ValidateResultSummary | null`.
245
- const s = validateSummary;
246
- pushSpan.setAttribute("push.preflight.validate.bypassed", true);
247
- if (s) {
248
- pushSpan.setAttribute("push.preflight.validate.bypassed.error_count", s.errorCount);
249
- pushSpan.setAttribute("push.preflight.validate.bypassed.warning_count", s.warningCount);
250
- pushSpan.setAttribute("push.preflight.validate.bypassed.remote_reachable", s.remoteReachable);
251
- if (s.issueCodes.length > 0) {
252
- pushSpan.setAttribute("push.preflight.validate.bypassed.issue_codes", s.issueCodes.join(","));
253
- }
254
- if (s.topIssueMessages.length > 0) {
255
- pushSpan.setAttribute("push.preflight.validate.bypassed.top_messages", JSON.stringify(s.topIssueMessages));
256
- }
257
- if (s.remoteFailureReason) {
258
- pushSpan.setAttribute("push.preflight.validate.bypassed.remote_failure_reason", s.remoteFailureReason);
259
- }
260
- if (s.remoteStatusCode !== null) {
261
- pushSpan.setAttribute("push.preflight.validate.bypassed.remote_status_code", s.remoteStatusCode);
262
- }
263
- }
264
- pushSpan.addEvent("preflight.validation_failed_but_forced", {
265
- error_count: s?.errorCount ?? -1,
266
- warning_count: s?.warningCount ?? -1,
267
- remote_reachable: s?.remoteReachable ?? false,
268
- issue_codes: s?.issueCodes.join(",") ?? "",
269
- top_messages: s ? JSON.stringify(s.topIssueMessages) : "",
270
- remote_failure_reason: s?.remoteFailureReason ?? "",
271
- });
272
- // Also yell at the user on stderr so they can't miss it in their
273
- // terminal. JSON mode: stderr too — the user/agent reading JSON
274
- // output still sees stderr separately from the JSON envelope.
275
- const summary_line = s
276
- ? `--force used: bypassing ${s.errorCount} error(s) and ${s.warningCount} warning(s) from validate.` +
277
- (s.issueCodes.length
278
- ? ` Codes: ${s.issueCodes.join(", ")}.`
279
- : "") +
280
- (s.remoteReachable
281
- ? ""
282
- : ` Validate endpoint was unreachable (${s.remoteFailureReason ?? "unknown"}).`)
283
- : "--force used: bypassing validation failure.";
284
- stderrLog(`⚠️ ${summary_line}`);
132
+ else {
133
+ stderrLog(`${options.force ? "--force" : "--skip-validation"} is a no-op under the new push endpoint.`);
285
134
  }
286
135
  }
287
- else {
288
- pushSpan.addEvent("preflight.validation_skipped");
289
- }
136
+ // ─── Read every .aui.json under the project ───
137
+ // The new push endpoint expects a FULL bundle — we no longer split
138
+ // changes from baseline files. Read everything once up-front.
290
139
  if (!json)
291
- log(_jsx(StatusLine, { kind: "info", label: "Pushing agent changes..." }));
140
+ log(_jsx(StatusLine, { kind: "info", label: "Reading local agent files..." }));
292
141
  else
293
142
  stderrLog("Reading local files...");
294
- const spinner = json ? null : startSpinner("Reading local files...");
295
- const fileData = [];
296
- try {
143
+ const fileData = await withSpinner("Reading local files...", async (spinner) => {
144
+ const out = [];
297
145
  const files = await findAuiFiles(projectRoot);
298
- for (const file of files) {
299
- const parsed = parseAuiFile(file);
146
+ for (const f of files) {
147
+ const parsed = parseAuiFile(f);
300
148
  if (!parsed)
301
149
  continue;
302
- const relativePath = path.relative(projectRoot, file);
303
- let type = "unknown";
150
+ const relativePath = path.relative(projectRoot, f);
304
151
  const isToolPath = relativePath.startsWith("tools/") || relativePath.startsWith("tools\\");
305
- // Recognize agent.aui.json by filename (not just by the wrapper key),
306
- // mirroring `validate.tsx:222` which accepts both
307
- // { "general_settings": {...} } (wrapped `aui pull` writes this)
308
- // {...top-level fields...} (flat — Agent Builder skills write this)
309
- // Without this, a flat agent.aui.json fell through to type "unknown"
310
- // and `buildPushTasks` never had a chance to emit a
311
- // patch-general-settings task same "silently dropped" class of bug
312
- // the tool-modify branch had.
313
- const isSettingsPath = relativePath === "agent.aui.json";
314
- if (parsed.general_settings || isSettingsPath)
152
+ // Classify by filename first (the new /pull endpoint enforces
153
+ // canonical, stable filenames at the bundle root), then fall
154
+ // back to content-based detection for non-canonical files.
155
+ //
156
+ // Why filename-first: `agent.aui.json` historically was written
157
+ // wrapped `{ "general_settings": {...} }` by the legacy
158
+ // exportAgent path, so `parsed.general_settings` was a reliable
159
+ // probe. The new /pull endpoint returns the file body the
160
+ // server stored, and per the spec the canonical shape for
161
+ // `agent.aui.json` is the UNWRAPPED general_settings object
162
+ // (`{ name, objective, guardrails, ... }` directly). The
163
+ // wrapped-only check therefore mis-classified every freshly
164
+ // pulled `agent.aui.json` as "unknown" — purely a label bug
165
+ // (the file still uploaded fine) but confusing in the summary.
166
+ const basename = path.basename(relativePath);
167
+ let type = "unknown";
168
+ if (basename === "agent.aui.json")
315
169
  type = "general_settings";
170
+ else if (basename === "parameters.aui.json")
171
+ type = "parameters";
172
+ else if (basename === "entities.aui.json")
173
+ type = "entities";
174
+ else if (basename === "integrations.aui.json")
175
+ type = "integrations";
176
+ else if (basename === "rules.aui.json")
177
+ type = "rules";
316
178
  else if (parsed.tool || isToolPath)
317
179
  type = "tool";
180
+ else if (parsed.general_settings)
181
+ type = "general_settings"; // legacy wrapped
318
182
  else if (parsed.parameters)
319
183
  type = "parameters";
320
184
  else if (parsed.entities)
@@ -323,750 +187,651 @@ async function _push(pushSpan, agentCode, options = {}) {
323
187
  type = "integrations";
324
188
  else if (parsed.rules)
325
189
  type = "rules";
326
- fileData.push({ file: relativePath, type, data: parsed });
190
+ out.push({ file: relativePath, type, data: parsed });
327
191
  }
192
+ spinner.succeed(`Read ${out.length} files`);
328
193
  if (json)
329
- stderrLog(`Read ${fileData.length} files`);
330
- else
331
- spinner.succeed(`Read ${fileData.length} files`);
332
- const summary = {};
333
- for (const fd of fileData) {
334
- summary[fd.type] = (summary[fd.type] || 0) + 1;
335
- }
336
- const pushFiles = fileData.map((fd) => ({
337
- file: fd.file,
338
- type: fd.type,
339
- }));
340
- if (!json) {
341
- log(_jsx(PushFileSummary, { agentCode: projectConfig.agent_code || projectConfig.agent_id, agentId: projectConfig.agent_id, files: pushFiles, summary: summary }));
342
- }
343
- // ─── Git-based change detection ───
344
- const { getDiffSummary, getFileDiff, getItemLevelDiff: getItemDiff, hasOwnGitRepo, initAndCommitBaseline, commitBaseline, commitBaselineFiles, } = await import("../utils/git.js");
345
- if (!hasOwnGitRepo(projectRoot)) {
346
- pushSpan.setAttribute("push.exit_reason", "baseline_initialized");
347
- initAndCommitBaseline(projectRoot, "baseline: initialized from existing files");
348
- if (json) {
349
- outputJson({ dry_run: options.dryRun ?? false, baseline_initialized: true, has_changes: false });
350
- return;
351
- }
352
- log(_jsx(Box, { paddingX: 1, children: _jsx(Hint, { message: "Baseline initialized. Run `aui push` again after making changes." }) }));
353
- return;
354
- }
355
- const diff = hasOwnGitRepo(projectRoot)
356
- ? getDiffSummary(projectRoot)
357
- : null;
358
- if (diff && !diff.hasChanges) {
359
- pushSpan.setAttribute("push.exit_reason", "no_changes");
360
- if (json) {
361
- outputJson({ dry_run: options.dryRun ?? false, has_changes: false, changes: [] });
362
- return;
363
- }
364
- log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "success", label: "No changes detected since last import/push." }) }));
194
+ stderrLog(`Read ${out.length} files`);
195
+ return out;
196
+ });
197
+ if (fileData.length === 0) {
198
+ pushSpan.setAttribute("push.exit_reason", "no_files");
199
+ throw new CLIError("No .aui.json files found in this project to push.", {
200
+ suggestion: "Run `aui import` (or `aui pull`) to download the current agent bundle first.",
201
+ });
202
+ }
203
+ // The previous multipart endpoint required path-separator-free,
204
+ // unique filenames per request. The new JSON endpoint sends ONE
205
+ // canonical `bundle.json` — there are no filenames on the wire
206
+ // anymore, so the old collision check is gone. Tool files still
207
+ // need locally-unique basenames to avoid one .aui.json overwriting
208
+ // another in the project tree, but that's a project-layout issue,
209
+ // not a push-time one.
210
+ const summary = {};
211
+ for (const fd of fileData)
212
+ summary[fd.type] = (summary[fd.type] || 0) + 1;
213
+ const pushFiles = fileData.map((fd) => ({
214
+ file: fd.file,
215
+ type: fd.type,
216
+ }));
217
+ if (!json) {
218
+ log(_jsx(PushFileSummary, { agentCode: projectConfig.agent_code || projectConfig.agent_id, agentId: projectConfig.agent_id, files: pushFiles, summary: summary }));
219
+ }
220
+ // ─── Git-based diff (informational + baseline tracking) ───
221
+ // The new endpoint uploads the full bundle every time so the diff is
222
+ // *not* used to decide what to send. We still compute it for:
223
+ // - showing the user what they changed (UX)
224
+ // - dry-run preview
225
+ // - deciding whether to commit a baseline at the end
226
+ const { getDiffSummary, getFileDiff, getItemLevelDiff: getItemDiff, hasOwnGitRepo, initAndCommitBaseline, commitBaseline, } = await import("../utils/git.js");
227
+ if (!hasOwnGitRepo(projectRoot)) {
228
+ pushSpan.setAttribute("push.exit_reason", "baseline_initialized");
229
+ initAndCommitBaseline(projectRoot, "baseline: initialized from existing files");
230
+ if (json) {
231
+ outputJson({
232
+ dry_run: options.dryRun ?? false,
233
+ baseline_initialized: true,
234
+ has_changes: false,
235
+ });
365
236
  return;
366
237
  }
367
- if (diff && !json) {
368
- const changeFiles = diff.changedFiles.map((change) => {
369
- const result = {
370
- file: change.file,
371
- status: change.status,
372
- };
373
- if ((change.status === "modified" || change.status === "added") &&
374
- !change.file.startsWith("knowledge-hubs/")) {
375
- const arrayFileInfo = getArrayFileInfoForPush(change.file, projectRoot);
376
- if (arrayFileInfo) {
377
- const itemChanges = getItemDiff(projectRoot, change.file, arrayFileInfo.arrayKey, arrayFileInfo.itemKey);
378
- if (itemChanges.length > 0) {
379
- result.itemChanges = itemChanges.map((item) => ({
380
- key: item.key,
381
- status: item.status,
382
- fieldChanges: item.fieldChanges,
383
- }));
384
- }
238
+ log(_jsx(Box, { paddingX: 1, children: _jsx(Hint, { message: "Baseline initialized. Run `aui push` again to upload the current bundle." }) }));
239
+ return;
240
+ }
241
+ const diff = getDiffSummary(projectRoot);
242
+ if (diff && !json) {
243
+ const changeFiles = diff.changedFiles.map((change) => {
244
+ const result = {
245
+ file: change.file,
246
+ status: change.status,
247
+ };
248
+ if ((change.status === "modified" || change.status === "added") &&
249
+ !change.file.startsWith("knowledge-hubs/")) {
250
+ const arrayFileInfo = getArrayFileInfoForPush(change.file, projectRoot);
251
+ if (arrayFileInfo) {
252
+ const itemChanges = getItemDiff(projectRoot, change.file, arrayFileInfo.arrayKey, arrayFileInfo.itemKey);
253
+ if (itemChanges.length > 0) {
254
+ result.itemChanges = itemChanges.map((item) => ({
255
+ key: item.key,
256
+ status: item.status,
257
+ fieldChanges: item.fieldChanges,
258
+ }));
385
259
  }
386
- else if (change.status === "modified") {
387
- const fieldDiffs = getFileDiff(projectRoot, change.file);
388
- if (fieldDiffs.length > 0) {
389
- result.fieldDiffs = fieldDiffs;
390
- }
260
+ }
261
+ else if (change.status === "modified") {
262
+ const fieldDiffs = getFileDiff(projectRoot, change.file);
263
+ if (fieldDiffs.length > 0) {
264
+ result.fieldDiffs = fieldDiffs;
391
265
  }
392
266
  }
393
- return result;
394
- });
395
- log(_jsx(PushChangesView, { changes: changeFiles }));
396
- }
397
- if (options.dryRun) {
398
- pushSpan.setAttribute("push.exit_reason", "dry_run");
399
- if (isJsonMode()) {
400
- outputJson({
401
- dry_run: true,
402
- agent: {
403
- code: projectConfig.agent_code || projectConfig.agent_id,
404
- id: projectConfig.agent_id,
405
- },
406
- files: pushFiles,
407
- summary,
408
- changes: diff
409
- ? diff.changedFiles.map((change) => {
410
- const result = {
411
- file: change.file,
412
- status: change.status,
413
- };
414
- if (change.status === "modified" || change.status === "added") {
415
- const arrayFileInfo = getArrayFileInfoForPush(change.file, projectRoot);
416
- if (arrayFileInfo) {
417
- const itemChanges = getItemDiff(projectRoot, change.file, arrayFileInfo.arrayKey, arrayFileInfo.itemKey);
418
- if (itemChanges.length > 0) {
419
- result.item_changes = itemChanges;
420
- }
267
+ }
268
+ return result;
269
+ });
270
+ log(_jsx(PushChangesView, { changes: changeFiles }));
271
+ }
272
+ // ─── Dry run: don't contact the backend at all ───
273
+ if (options.dryRun) {
274
+ pushSpan.setAttribute("push.exit_reason", "dry_run");
275
+ if (json) {
276
+ outputJson({
277
+ dry_run: true,
278
+ agent: {
279
+ code: projectConfig.agent_code || projectConfig.agent_id,
280
+ id: projectConfig.agent_id,
281
+ },
282
+ files: pushFiles,
283
+ summary,
284
+ changes: diff
285
+ ? diff.changedFiles.map((change) => {
286
+ const result = {
287
+ file: change.file,
288
+ status: change.status,
289
+ };
290
+ if (change.status === "modified" || change.status === "added") {
291
+ const arrayFileInfo = getArrayFileInfoForPush(change.file, projectRoot);
292
+ if (arrayFileInfo) {
293
+ const itemChanges = getItemDiff(projectRoot, change.file, arrayFileInfo.arrayKey, arrayFileInfo.itemKey);
294
+ if (itemChanges.length > 0) {
295
+ result.item_changes = itemChanges;
421
296
  }
422
- else if (change.status === "modified") {
423
- const fieldDiffs = getFileDiff(projectRoot, change.file);
424
- if (fieldDiffs.length > 0) {
425
- result.field_diffs = fieldDiffs;
426
- }
297
+ }
298
+ else if (change.status === "modified") {
299
+ const fieldDiffs = getFileDiff(projectRoot, change.file);
300
+ if (fieldDiffs.length > 0) {
301
+ result.field_diffs = fieldDiffs;
427
302
  }
428
303
  }
429
- return result;
430
- })
431
- : [],
432
- });
433
- return;
434
- }
435
- log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "muted", label: "Dry run \u2014 no changes pushed." }) }));
436
- return;
437
- }
438
- // ─── Agent Config Push ───
439
- //
440
- // Knowledge Bases used to be pushed here (BEFORE entity writes) with a
441
- // special pre-step that pushed integrations even earlier so KB uploads
442
- // would find their integration. That ordering caused two production
443
- // bugs: integrations were PATCHed before the parameters they reference
444
- // existed (CTS-12425), and tools were pushed in parallel with their
445
- // dependencies (CTS-12426). The KB push has been moved into the unified
446
- // dependency-ordered flow below — see `pushKnowledgeHubs` invocation.
447
- if (!diff || !diff.hasChanges) {
448
- pushSpan.setAttribute("push.exit_reason", "no_agent_config_changes");
449
- log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "success", label: "No agent config changes to push." }) }));
304
+ }
305
+ return result;
306
+ })
307
+ : [],
308
+ });
450
309
  return;
451
310
  }
452
- const session = await getValidSession();
453
- if (!session) {
454
- throw new AuthenticationError("Session expired.");
455
- }
456
- // Use scope level from --scope-level flag, or from .auirc (saved during import)
457
- const effectiveScopeLevel = options.scopeLevel ||
458
- projectConfig.scope_level;
459
- if (effectiveScopeLevel && effectiveScopeLevel !== "network") {
460
- log(_jsx(StatusLine, { kind: "info", label: `Scope level: ${effectiveScopeLevel} (from ${options.scopeLevel ? "flag" : ".auirc"})` }));
461
- }
462
- const agentSettingsParams = await resolveAgentSettingsParams(config, projectConfig, session, projectRoot, effectiveScopeLevel);
463
- const client = new AUIClient({
311
+ log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "muted", label: `Dry run — would push ${fileData.length} file(s) as a single bundle. Nothing uploaded.` }) }));
312
+ return;
313
+ }
314
+ // CHERRY-PICK NOTE (53a6024 + d7d91ab — 2026-05-24)
315
+ // ------------------------------------------------------------------
316
+ // The incoming hunk added a `hasKbChanges` short-circuit + per-entity
317
+ // `buildPushTasks` plumbing to the records-mode push pipeline. In our
318
+ // dual-mode CLI that pipeline lives in
319
+ // `commands/legacy/push-records-mode.tsx`, NOT here `push.tsx`
320
+ // assembles a single bundle and POSTs it via `/push`.
321
+ //
322
+ // For BUNDLE-MODE pushes, "KB-only changes" can't be silently
323
+ // dropped: the assembled bundle is sent regardless (the server
324
+ // diff-compares against the previous revision), and the KB push
325
+ // is its own independent step via `pushKnowledgeHubs` below. The
326
+ // "no pushable changes" short-circuit in bundle-mode is already
327
+ // handled by the `diff.hasChanges` check higher up.
328
+ //
329
+ // The EQUIVALENT `hasKbChanges` fix for RECORDS-MODE pushes is
330
+ // applied directly inside `runLegacyPush` — see
331
+ // commands/legacy/push-records-mode.tsx where the legacy
332
+ // `pushTasks` short-circuit lives. Keep both in sync if the
333
+ // hasKbChanges semantics ever change again.
334
+ // ─── Resolve a draft version to push to ───
335
+ // The new endpoint enforces `status === draft`; resolve up-front so we
336
+ // surface a clean error instead of a 422 round-trip.
337
+ const session = await getValidSession();
338
+ if (!session) {
339
+ throw new AuthenticationError("Session expired.");
340
+ }
341
+ // `--agent-id` overlay: if the user passed an explicit target agent,
342
+ // dual-shape resolve it (agent_management_id first, fall back to
343
+ // network_id on 404) and overlay the resolved IDs onto `projectConfig`.
344
+ // Downstream (`resolveVersionDraft` → `lookupAgentManagementInfoForPush`)
345
+ // then picks up the override via the same code path as a normal
346
+ // `.auirc`-driven push — no special-casing.
347
+ let resolveProjectConfig = projectConfig;
348
+ if (options.agentId) {
349
+ const overlayClient = new AUIClient({
464
350
  baseUrl: config.apiUrl,
465
351
  authToken: config.authToken,
466
352
  accountId: config.accountId,
467
353
  organizationId: config.organizationId,
468
354
  environment: config.environment,
469
355
  });
470
- const pushLogDir = path.join(projectRoot, ".aui", "push-logs");
471
- fs.mkdirSync(pushLogDir, { recursive: true });
472
- client.setPushLogDir(pushLogDir);
473
- if (options.apiKey) {
474
- saveAgentSettingsApiKey(options.apiKey);
475
- client.setAgentSettingsApiKey(options.apiKey);
476
- }
477
- const savedApiKey = loadAgentSettingsApiKey();
478
- if (savedApiKey && !options.apiKey) {
479
- client.setAgentSettingsApiKey(savedApiKey);
480
- }
481
- // Version management: push is only allowed to draft versions.
482
- // If the project has version_id in .auirc or --version-id is passed,
483
- // we validate it's a draft. If no version context exists, we auto-detect
484
- // available drafts. Push is rejected if no draft is found.
485
- //
486
- // Wrapped in an `aui.push.preflight.resolve-version` span so a hang on
487
- // listAgents / listVersions / getVersion shows up clearly in Logfire
488
- // instead of being lumped under the parent push span. This is the
489
- // step that hits agent-management with up to 3 sequential calls.
490
- let prePushDraft = null;
491
- if (projectConfig.version_id || options.versionId) {
492
- const resolveTracer = getTracer();
493
- prePushDraft = await resolveTracer.startActiveSpan("aui.push.preflight.resolve-version", async (rSpan) => {
494
- rSpan.setAttribute("push.preflight.step", "resolve-version");
495
- rSpan.setAttribute("push.preflight.has_explicit_version_id", !!options.versionId);
496
- rSpan.setAttribute("push.preflight.has_auirc_version_id", !!projectConfig.version_id);
497
- if (projectConfig.agent_id) {
498
- rSpan.setAttribute("push.preflight.network_id", projectConfig.agent_id);
499
- }
500
- try {
501
- const draft = await resolveVersionDraft(config, projectConfig, session, options.versionId);
502
- rSpan.setAttribute("push.preflight.resolved_version_id", draft.versionId);
503
- rSpan.setAttribute("push.preflight.resolved_version_label", draft.label);
504
- rSpan.setAttribute("push.preflight.resolved_agent_id", draft.agentId);
505
- rSpan.setStatus({ code: SpanStatusCode.OK });
506
- return draft;
507
- }
508
- catch (err) {
509
- const msg = err instanceof Error ? err.message : String(err);
510
- rSpan.setStatus({ code: SpanStatusCode.ERROR, message: msg });
511
- rSpan.recordException(err instanceof Error ? err : new Error(msg));
512
- throw err;
513
- }
514
- finally {
515
- rSpan.end();
516
- }
517
- });
518
- agentSettingsParams.version_id = prePushDraft.versionId;
519
- // Per a117251 (alboim): every agent-settings write body must carry the
520
- // agent-management UUID. Setting it on `agentSettingsParams` here means
521
- // every subsequent `client.<entity>` call funnels through `versionBody`
522
- // and includes `agent_id` automatically.
523
- agentSettingsParams.agent_id = prePushDraft.agentId;
524
- pushSpan.setAttribute("push.version_id", prePushDraft.versionId);
525
- pushSpan.setAttribute("push.version_label", prePushDraft.label);
526
- pushSpan.setAttribute("push.agent_management_id", prePushDraft.agentId);
527
- // Persist agent_management_id back to .auirc on first push so subsequent
528
- // pushes skip the listAgents lookup. Mirrors what
529
- // `resolvePushAgentManagementId` does in the legacy branch — keeps both
530
- // paths converging on the same .auirc state. Non-fatal if the write
531
- // fails (we already have the id in memory for this push).
532
- if (!projectConfig.agent_management_id) {
533
- try {
534
- saveProjectConfig({ ...projectConfig, agent_management_id: prePushDraft.agentId }, projectRoot);
535
- pushSpan.addEvent("auirc.agent_management_id_persisted_from_draft", {
536
- agent_management_id: prePushDraft.agentId,
537
- });
538
- }
539
- catch (err) {
540
- if (process.env.AUI_DEBUG) {
541
- console.warn("[debug] failed to persist agent_management_id back to .auirc:", err instanceof Error ? err.message : err);
542
- }
543
- }
544
- }
545
- }
546
- else {
547
- // Legacy push (no version_id) — still need agent_id on write bodies.
548
- // resolvePushAgentManagementId reads from .auirc first (cached by
549
- // import-agent / pull-agent), falls back to listAgents lookup + writes
550
- // back to .auirc so subsequent pushes skip the lookup.
551
- pushSpan.addEvent("preflight.no_draft_version_required", {
552
- reason: "legacy push (no version_id in .auirc or --version-id flag)",
553
- });
554
- const legacyAgentId = await resolvePushAgentManagementId(config, projectConfig, session, projectRoot);
555
- agentSettingsParams.agent_id = legacyAgentId;
556
- pushSpan.setAttribute("push.agent_management_id", legacyAgentId);
356
+ const overlayKey = loadAgentSettingsApiKey();
357
+ if (overlayKey)
358
+ overlayClient.setAgentSettingsApiKey(overlayKey);
359
+ const resolved = await resolveExplicitAgentForPush(overlayClient, options.agentId);
360
+ resolveProjectConfig = {
361
+ ...projectConfig,
362
+ agent_id: resolved.networkId ?? projectConfig.agent_id,
363
+ agent_management_id: resolved.agentManagementId,
364
+ };
365
+ pushSpan.setAttribute("push.agent_id_override", options.agentId);
366
+ pushSpan.setAttribute("push.agent_id_override_resolved_as", resolved.via);
367
+ pushSpan.setAttribute("push.agent_management_id_overlay", resolved.agentManagementId);
368
+ }
369
+ const resolveTracer = getTracer();
370
+ const prePushDraft = await resolveTracer.startActiveSpan("aui.push.preflight.resolve-version", async (rSpan) => {
371
+ rSpan.setAttribute("push.preflight.step", "resolve-version");
372
+ rSpan.setAttribute("push.preflight.has_explicit_version_id", !!options.versionId);
373
+ rSpan.setAttribute("push.preflight.has_explicit_agent_id", !!options.agentId);
374
+ rSpan.setAttribute("push.preflight.has_auirc_version_id", !!projectConfig.version_id);
375
+ if (resolveProjectConfig.agent_id) {
376
+ rSpan.setAttribute("push.preflight.network_id", resolveProjectConfig.agent_id);
557
377
  }
558
- const pushTasks = buildPushTasks(diff, fileData, projectRoot, getFileDiff);
559
- pushSpan.setAttribute("push.task_count", pushTasks.length);
560
- if (diff) {
561
- pushSpan.setAttribute("push.diff.added", diff.totalAdded);
562
- pushSpan.setAttribute("push.diff.modified", diff.totalModified);
563
- pushSpan.setAttribute("push.diff.deleted", diff.totalDeleted);
378
+ try {
379
+ const draft = await resolveVersionDraft(config, resolveProjectConfig, session, options.versionId);
380
+ rSpan.setAttribute("push.preflight.resolved_version_id", draft.versionId);
381
+ rSpan.setAttribute("push.preflight.resolved_version_label", draft.label);
382
+ rSpan.setAttribute("push.preflight.resolved_agent_id", draft.agentId);
383
+ rSpan.setStatus({ code: SpanStatusCode.OK });
384
+ return draft;
564
385
  }
565
- // pushKnowledgeHubs runs as a separate step; short-circuiting on
566
- // pushTasks alone would silently no-op KB-only pushes.
567
- const hasKbChanges = diff?.changedFiles?.some((c) => c.file.startsWith("knowledge-hubs/")) ??
568
- false;
569
- if (pushTasks.length === 0 && !hasKbChanges) {
570
- pushSpan.setAttribute("push.exit_reason", "no_pushable_tasks");
571
- log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "success", label: "No pushable changes detected." }) }));
572
- return;
386
+ catch (err) {
387
+ const msg = err instanceof Error ? err.message : String(err);
388
+ rSpan.setStatus({ code: SpanStatusCode.ERROR, message: msg });
389
+ rSpan.recordException(err instanceof Error ? err : new Error(msg));
390
+ throw err;
573
391
  }
574
- if (pushTasks.length > 0) {
575
- log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "info", label: `Pushing ${pushTasks.length} change(s)...` }) }));
392
+ finally {
393
+ rSpan.end();
576
394
  }
577
- let succeeded = 0;
578
- let failed = 0;
579
- let authFailed = false;
580
- const succeededFiles = [];
581
- const authFailedTasks = [];
582
- const pushFailures = [];
583
- const isAuthError = (err) => {
584
- if (!(err instanceof Error))
585
- return false;
586
- const msg = err.message.toLowerCase();
587
- const code = err.statusCode;
588
- return (code === 401 ||
589
- code === 403 ||
590
- msg.includes("unauthorized") ||
591
- msg.includes("forbidden") ||
592
- msg.includes("not a member") ||
593
- msg.includes("not member"));
594
- };
595
- const agentCodeStr = projectConfig.agent_code || projectConfig.agent_id || "unknown";
596
- const agentIdStr = projectConfig.agent_id || "unknown";
597
- /**
598
- * Run one push step (a group of related tasks for one entity-type)
599
- * STRICTLY SEQUENTIALLY. There is intentionally no `parallel` flag — the
600
- * agent-settings backend has no optimistic locking and concurrent writes
601
- * to the same agent silently merge / drop unresolvable references / re-
602
- * sequence array items (see file header doc + CTS-12340 / -12425 / -12426
603
- * for prior incidents). If you think you need to parallelize, you don't.
604
- */
605
- const pushStep = async (tasks, label) => {
606
- if (tasks.length === 0)
607
- return true;
608
- log(_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.info, children: [icons.bullet, " ", label, "..."] }) }));
609
- const stepFailed = [];
610
- try {
611
- for (const task of tasks) {
612
- const taskResult = {
613
- label: task.label,
614
- status: "success",
615
- };
616
- try {
617
- const result = await executePushTask(client, agentSettingsParams, task);
618
- succeeded++;
619
- if (task.file)
620
- succeededFiles.push(task.file);
621
- if (isAlreadyAbsentResult(result)) {
622
- taskResult.label = `${task.label} (already absent)`;
623
- }
624
- }
625
- catch (err) {
626
- if (isAuthError(err)) {
627
- authFailed = true;
628
- authFailedTasks.push(task);
629
- taskResult.status = "auth-failed";
630
- }
631
- else {
632
- failed++;
633
- const errMsg = err instanceof Error ? err.message : String(err);
634
- const failure = {
635
- label: task.label,
636
- file: task.file,
637
- error: errMsg,
638
- };
639
- pushFailures.push(failure);
640
- stepFailed.push(failure);
641
- taskResult.status = "failed";
642
- taskResult.error = errMsg;
643
- }
644
- }
645
- log(_jsx(Box, { paddingX: 2, children: _jsx(PushTaskLine, { result: taskResult }) }));
646
- }
647
- return stepFailed.length === 0 && !authFailed;
648
- }
649
- catch (err) {
650
- // Don't swallow — a thrown error here means the loop body itself
651
- // (not a per-task failure) blew up. Letting the original `} catch
652
- // { return false; }` swallow it caused exit 0 with no telemetry.
653
- throw err;
654
- }
655
- };
656
- const paramTasks = pushTasks.filter((t) => t.type === "put-parameters" ||
657
- t.type === "create-parameter" ||
658
- t.type === "patch-parameter" ||
659
- t.type === "delete-parameter");
660
- const entityTasks = pushTasks.filter((t) => t.type === "put-entities" ||
661
- t.type === "create-entity" ||
662
- t.type === "patch-entity" ||
663
- t.type === "delete-entity");
664
- const integrationUpsertTasks = pushTasks.filter(isIntegrationUpsertTask);
665
- const integrationDeleteTasks = pushTasks.filter((t) => t.type === "delete-integration");
666
- const toolTasks = pushTasks.filter((t) => t.type === "patch-tool" ||
667
- t.type === "create-tool" ||
668
- t.type === "delete-tool");
669
- const settingsTasks = pushTasks.filter((t) => t.type === "patch-general-settings");
670
- const rulesTasks = pushTasks.filter((t) => t.type === "put-rules");
671
- // ─── Push order — see file header for rationale ─────────────────────
672
- //
673
- // Phase 1: UPSERTS, top-down by dependency (least → most depends-on).
674
- // Every step is sequential by construction (`pushStep` has no
675
- // parallel mode). Do not work around this — the agent-settings
676
- // backend silently merges / drops unresolvable refs on concurrent
677
- // writes.
678
- // 1. Parameters — referenced by entities, integrations, tools, rules.
679
- await pushStep(paramTasks, "Pushing parameters");
680
- // 2. Entities — reference parameters; referenced by tools, integrations.
681
- await pushStep(entityTasks, "Pushing entities");
682
- // 3. Integration upserts — reference parameters / entities. Must be
683
- // pushed BEFORE knowledge-base uploads (KBs attach to integrations)
684
- // AND before tools (tools reference integration codes).
685
- await pushStep(integrationUpsertTasks, "Pushing integrations");
686
- // 4. Knowledge Bases — reference integrations existing on the platform.
687
- // KB failures are folded into the same `failed` counter / pushFailures
688
- // array as agent-settings writes, so they hit the "X failed." line, the
689
- // JSON envelope, and the non-zero exit code (BFF contract: zero silent
690
- // errors anywhere in the push pipeline).
691
- const kbResult = await pushKnowledgeHubs(projectRoot, projectConfig);
692
- pushSpan.setAttribute("push.kb.ok", kbResult.ok);
693
- pushSpan.setAttribute("push.kb.failures", kbResult.failures.length);
694
- if (!kbResult.ok) {
695
- pushSpan.addEvent("kb.failures_folded_into_pushFailures", {
696
- count: kbResult.failures.length,
395
+ });
396
+ pushSpan.setAttribute("push.version_id", prePushDraft.versionId);
397
+ pushSpan.setAttribute("push.version_label", prePushDraft.label);
398
+ pushSpan.setAttribute("push.agent_management_id", prePushDraft.agentId);
399
+ // First-push migration: persist agent_management_id back to .auirc so
400
+ // subsequent pushes skip the listAgents lookup. Non-fatal if the
401
+ // write fails (we already have the id in memory for this push).
402
+ if (!projectConfig.agent_management_id) {
403
+ try {
404
+ saveProjectConfig({ ...projectConfig, agent_management_id: prePushDraft.agentId }, projectRoot);
405
+ pushSpan.addEvent("auirc.agent_management_id_persisted_from_draft", {
406
+ agent_management_id: prePushDraft.agentId,
697
407
  });
698
- for (const kbFailure of kbResult.failures) {
699
- failed++;
700
- pushFailures.push(kbFailure);
408
+ }
409
+ catch (err) {
410
+ if (process.env.AUI_DEBUG) {
411
+ console.warn("[debug] failed to persist agent_management_id back to .auirc:", err instanceof Error ? err.message : err);
701
412
  }
702
413
  }
703
- // 5. Tools — reference parameters, entities, integrations. Sequential:
704
- // parallel tool pushes caused inter-tool race conditions in
705
- // production (chain-activation, success-rule re-sequencing).
706
- await pushStep(toolTasks, "Pushing tools");
707
- // 6. Rules — reference tools, parameters, entities.
708
- await pushStep(rulesTasks, "Pushing rules");
709
- // 7. General settings — mostly standalone but may reference defaults.
710
- await pushStep(settingsTasks, "Pushing general settings");
711
- // Phase 2: DELETES, bottom-up. Integrations get deleted last so any
712
- // tool / entity update above that still referenced them succeeds first.
713
- await pushStep(integrationDeleteTasks, "Removing integrations");
714
- // Phase 3: Snapshot — runs at the very end of `_push` (see below).
715
- // Auth fallback
716
- if (authFailed && authFailedTasks.length > 0 && !savedApiKey) {
717
- // The auth fallback prompts for an API key. In a non-TTY environment
718
- // (`agent-builder-bff` E2B sandbox, CI, JSON mode being piped) the
719
- // inquirer prompt would block forever waiting on stdin and the
720
- // sandbox would eventually SIGTERM the process — exactly the silent
721
- // failure mode users were hitting. Detect that up front
722
- // and throw a structured AuthenticationError so handleError can
723
- // print it, emit the JSON envelope, and exit non-zero.
724
- const isInteractive = !json &&
725
- process.stdin.isTTY === true &&
726
- process.stdout.isTTY === true;
727
- if (!isInteractive) {
728
- failed += authFailedTasks.length;
729
- pushSpan.addEvent("auth.fallback.non_interactive_rejected", {
730
- failed_task_count: authFailedTasks.length,
731
- });
732
- throw new AuthenticationError(`Authentication failed for ${authFailedTasks.length} push task(s); cannot prompt for an API key (non-interactive session).`, {
733
- suggestion: "Pass --api-key <key>, set AUI_AGENT_TOOLS_API_KEY, or run `aui login` to refresh credentials.",
734
- });
414
+ }
415
+ // ─── Create API client ───
416
+ const client = new AUIClient({
417
+ baseUrl: config.apiUrl,
418
+ authToken: config.authToken,
419
+ accountId: config.accountId,
420
+ organizationId: config.organizationId,
421
+ environment: config.environment,
422
+ });
423
+ const pushLogDir = path.join(projectRoot, ".aui", "push-logs");
424
+ fs.mkdirSync(pushLogDir, { recursive: true });
425
+ client.setPushLogDir(pushLogDir);
426
+ if (options.apiKey) {
427
+ saveAgentSettingsApiKey(options.apiKey);
428
+ client.setAgentSettingsApiKey(options.apiKey);
429
+ }
430
+ const savedApiKey = loadAgentSettingsApiKey();
431
+ if (savedApiKey && !options.apiKey) {
432
+ client.setAgentSettingsApiKey(savedApiKey);
433
+ }
434
+ // ─── Mode dispatch: blob (new JSON push/pull) vs records (legacy) ───
435
+ //
436
+ // Every agent now carries an `Agent.bundle_mode` boolean (added
437
+ // 2026-05-24, see Notion "Agent Settings Push/Pull - Integration
438
+ // Guide" §2). The two surfaces are MUTUALLY EXCLUSIVE on the
439
+ // server — calling the wrong one returns `422 mode mismatch`. We
440
+ // detect the mode here (one `getAgent` call against an id we already
441
+ // have from the draft resolution above) and either continue with
442
+ // the blob-mode pipeline below OR hand off to the legacy per-entity
443
+ // pipeline in `./legacy/push-records-mode.tsx`.
444
+ //
445
+ // Why the dispatch lives here, not at the top of `_push`:
446
+ // - We need `agent_management_id` to look up the AgentInfo, and
447
+ // the cheapest way to get it (without an extra listAgents) is
448
+ // to wait for `resolveVersionDraft` to do its own lookup —
449
+ // `prePushDraft.agentId` is already populated above, and
450
+ // `prePushDraft.agentInfo` carries the full AgentInfo so the
451
+ // dispatcher can read `bundle_mode` without a second round-trip.
452
+ // - The legacy module is fully self-contained (it re-loads
453
+ // projectConfig, re-resolves the draft, re-scans files); we're
454
+ // paying a small duplication cost on records-mode pushes for a
455
+ // much cleaner separation of code paths. Records-mode is the
456
+ // fallback for unmigrated agents — almost all new agents are
457
+ // blob-mode, so the duplicate cost is rare and bounded.
458
+ // - The KB push below is mode-agnostic but the legacy pipeline
459
+ // ALREADY does its own KB push as part of its dependency-
460
+ // ordered flow. Dispatching BEFORE the KB block here keeps both
461
+ // paths' KB handling identical and avoids double-uploads.
462
+ const modeResolution = await detectAgentBundleMode(client, prePushDraft.agentId, prePushDraft.agentInfo);
463
+ pushSpan.setAttribute("push.dispatch.mode", modeResolution.mode);
464
+ pushSpan.setAttribute("push.dispatch.mode_source", modeResolution.source);
465
+ if (modeResolution.mode === "records") {
466
+ pushSpan.addEvent("dispatch.records_mode_handoff", {
467
+ agent_management_id: prePushDraft.agentId,
468
+ version_id: prePushDraft.versionId,
469
+ source: modeResolution.source,
470
+ });
471
+ // Hand off to the legacy per-entity pipeline. The legacy module
472
+ // re-derives everything from `options` + `agentCode` + on-disk
473
+ // `.auirc`, so we just forward the surface options that map
474
+ // cleanly. Blob-mode-only options (--agent-id, --caller,
475
+ // --commit-message) are intentionally dropped — they have no
476
+ // meaning in records-mode.
477
+ return runLegacyPush(pushSpan, _agentCode, {
478
+ force: options.force,
479
+ skipValidation: options.skipValidation,
480
+ dryRun: options.dryRun,
481
+ apiKey: options.apiKey,
482
+ versionId: options.versionId,
483
+ scopeLevel: options.scopeLevel,
484
+ });
485
+ }
486
+ // ─── Knowledge-base push (separate from .aui.json bundle) ───
487
+ // KBs are real content assets (PDFs, etc.) under `knowledge-hubs/`,
488
+ // uploaded via the KB view client. KB failures fold into the same
489
+ // `pushFailures` array as bundle failures so they hit the JSON
490
+ // envelope and the non-zero exit code (BFF contract).
491
+ const pushFailures = [];
492
+ let kbFailedCount = 0;
493
+ const kbResult = await pushKnowledgeHubs(projectRoot, projectConfig);
494
+ pushSpan.setAttribute("push.kb.ok", kbResult.ok);
495
+ pushSpan.setAttribute("push.kb.failures", kbResult.failures.length);
496
+ if (!kbResult.ok) {
497
+ pushSpan.addEvent("kb.failures_folded_into_pushFailures", {
498
+ count: kbResult.failures.length,
499
+ });
500
+ for (const kbFailure of kbResult.failures) {
501
+ kbFailedCount++;
502
+ pushFailures.push(kbFailure);
503
+ }
504
+ }
505
+ // ─── Single Push call ───
506
+ // The new JSON endpoint expects an ASSEMBLED bundle, not files. We
507
+ // build the bundle from the in-memory `fileData` (already loaded
508
+ // and JSON-parsed earlier in this function) using the
509
+ // `assembleBundleFromFiles` helper, which handles both wrapped and
510
+ // flat shapes for each canonical file plus tool-file unwrapping.
511
+ const assembledBundle = assembleBundleFromFiles(fileData, {
512
+ schemaVersion: 1,
513
+ });
514
+ // Diagnostic counts for the spinner and span attrs. The bundle
515
+ // doesn't carry the raw `.aui.json` count anymore — it's domain-
516
+ // sliced — so report the per-domain sizes instead.
517
+ const bundleSummary = {
518
+ has_general_settings: !!assembledBundle.general_settings,
519
+ parameters: assembledBundle.parameters?.length ?? 0,
520
+ entities: assembledBundle.entities?.length ?? 0,
521
+ integrations: assembledBundle.integrations?.length ?? 0,
522
+ rules: assembledBundle.rules?.length ?? 0,
523
+ // `assembleBundleFromFiles` emits the tools array under the
524
+ // server-canonical `agent_tools` key, but historical callers
525
+ // may set the legacy `tools` key — count either so the span /
526
+ // summary doesn't lie about an "empty tools" push when tools
527
+ // were actually included under the legacy key.
528
+ tools: (assembledBundle.agent_tools?.length ?? 0) +
529
+ (assembledBundle.agent_tools ? 0 : assembledBundle.tools?.length ?? 0),
530
+ };
531
+ const bundleEntityCount = bundleSummary.parameters +
532
+ bundleSummary.entities +
533
+ bundleSummary.integrations +
534
+ bundleSummary.rules +
535
+ bundleSummary.tools +
536
+ (bundleSummary.has_general_settings ? 1 : 0);
537
+ const PUSH_MAX_ATTEMPTS = 4;
538
+ const PUSH_RETRY_BASE_MS = 1000;
539
+ let pushResult = null;
540
+ let pushAttempts = 0;
541
+ let lastError;
542
+ const pushTracer = getTracer();
543
+ for (let attempt = 1; attempt <= PUSH_MAX_ATTEMPTS; attempt++) {
544
+ pushAttempts = attempt;
545
+ const label = attempt === 1
546
+ ? `Pushing bundle (${bundleEntityCount} item(s)) to draft ${prePushDraft.label}...`
547
+ : `Retrying push (attempt ${attempt}/${PUSH_MAX_ATTEMPTS})...`;
548
+ if (json)
549
+ stderrLog(label);
550
+ const attemptSpinner = json ? null : startSpinner(label);
551
+ const attemptOutcome = await pushTracer.startActiveSpan("aui.push.task.bundle", async (pSpan) => {
552
+ pSpan.setAttribute("push.task.type", "bundle");
553
+ pSpan.setAttribute("push.task.label", label);
554
+ pSpan.setAttribute("push.bundle.attempt", attempt);
555
+ pSpan.setAttribute("push.bundle.max_attempts", PUSH_MAX_ATTEMPTS);
556
+ pSpan.setAttribute("push.bundle.parameters_count", bundleSummary.parameters);
557
+ pSpan.setAttribute("push.bundle.entities_count", bundleSummary.entities);
558
+ pSpan.setAttribute("push.bundle.integrations_count", bundleSummary.integrations);
559
+ pSpan.setAttribute("push.bundle.rules_count", bundleSummary.rules);
560
+ pSpan.setAttribute("push.bundle.tools_count", bundleSummary.tools);
561
+ pSpan.setAttribute("push.bundle.has_general_settings", bundleSummary.has_general_settings);
562
+ pSpan.setAttribute("push.bundle.agent_id", prePushDraft.agentId);
563
+ pSpan.setAttribute("push.bundle.version_id", prePushDraft.versionId);
564
+ if (options.commitMessage) {
565
+ pSpan.setAttribute("push.bundle.commit_message_len", options.commitMessage.length);
735
566
  }
736
- pushSpan.addEvent("auth.fallback.api_key_prompted", {
737
- failed_task_count: authFailedTasks.length,
738
- });
739
- log(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StatusLine, { kind: "warning", label: "Authentication failed. Your access token may not have permission." }), _jsx(Hint, { message: "You can provide an API key as a fallback. It will be saved to ~/.aui/agent-settings-key" })] }));
740
- const { key } = await inquirer.prompt([
741
- {
742
- type: "password",
743
- name: "key",
744
- message: "Paste the Agent Settings API key (or press Enter to skip):",
745
- mask: "*",
746
- },
747
- ]);
748
- if (key && key.trim()) {
749
- saveAgentSettingsApiKey(key.trim());
750
- client.setAgentSettingsApiKey(key.trim());
751
- pushSpan.addEvent("auth.fallback.api_key_provided", {
752
- retrying_task_count: authFailedTasks.length,
567
+ try {
568
+ const result = await client.agentManagement.pushVersionBlobs(prePushDraft.agentId, prePushDraft.versionId, {
569
+ // `--caller` overrides the default; falls back to `cli`
570
+ // when not set. Server enum is strictly
571
+ // `agent_builder` | `ui` | `cli` — anything else 422s.
572
+ caller: options.caller ?? "cli",
573
+ commit_message: options.commitMessage,
574
+ // `pushed_by` (added 2026-05-25 per backend team): records
575
+ // the user id of the person doing the push on the new
576
+ // `agent_version_blob_revisions` row alongside `caller`.
577
+ // `caller` tells you WHICH surface invoked /push (cli /
578
+ // ui / agent_builder); `pushed_by` tells you WHO. Session
579
+ // is guaranteed non-null by the throw on line 585 above,
580
+ // so `session.user_id` is always populated here. Omitted
581
+ // only if the session somehow lacks a user_id (defensive
582
+ // — JWT claim should always carry one).
583
+ ...(session.user_id ? { pushed_by: session.user_id } : {}),
584
+ bundle: assembledBundle,
753
585
  });
754
- log(_jsx(StatusLine, { kind: "success", label: "Key saved." }));
755
- log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "info", label: `Retrying ${authFailedTasks.length} change(s) with API key...` }) }));
756
- authFailed = false;
757
- const retryTasks = [...authFailedTasks];
758
- authFailedTasks.length = 0;
759
- for (const task of retryTasks) {
760
- const retrySpinner = startSpinner(task.label);
761
- try {
762
- await executePushTask(client, agentSettingsParams, task);
763
- succeeded++;
764
- if (task.file)
765
- succeededFiles.push(task.file);
766
- retrySpinner.succeed(task.label);
586
+ if (result.success) {
587
+ pSpan.setStatus({ code: SpanStatusCode.OK });
588
+ pSpan.setAttribute("push.bundle.outcome", "success");
589
+ if (result.data?.new_version_tag) {
590
+ pSpan.setAttribute("push.bundle.new_version_tag", result.data.new_version_tag);
767
591
  }
768
- catch (err) {
769
- failed++;
770
- const errMsg = err instanceof Error ? err.message : String(err);
771
- pushFailures.push({
772
- label: task.label,
773
- file: task.file,
774
- error: errMsg,
775
- });
776
- retrySpinner.fail(task.label);
777
- log(_jsx(ErrorDisplay, { error: err }));
592
+ if (result.data?.revision_id) {
593
+ pSpan.setAttribute("push.bundle.revision_id", result.data.revision_id);
778
594
  }
779
- }
780
- }
781
- else {
782
- failed += authFailedTasks.length;
783
- pushSpan.addEvent("auth.fallback.api_key_skipped", {
784
- uncovered_task_count: authFailedTasks.length,
785
- });
786
- }
787
- }
788
- else if (authFailed && authFailedTasks.length > 0) {
789
- failed += authFailedTasks.length;
790
- pushSpan.addEvent("auth.fallback.saved_key_still_failed", {
791
- failed_task_count: authFailedTasks.length,
792
- });
793
- log(_jsx(ErrorDisplay, { error: new AuthenticationError("Auth failed even with saved API key.", {
794
- suggestion: "Try: rm ~/.aui/agent-settings-key",
795
- }) }));
796
- }
797
- const logRelPath = path.relative(projectRoot, pushLogDir);
798
- pushSpan.setAttribute("push.succeeded", succeeded);
799
- pushSpan.setAttribute("push.failed", failed);
800
- if (pushFailures.length > 0) {
801
- pushSpan.setAttribute("push.failures", JSON.stringify(pushFailures));
802
- }
803
- // ─── Push Snapshot (last step — after entity push) ───
804
- // The snapshot captures the local file state and uploads it as the
805
- // source of truth for this push. Runs regardless of entity-push outcomes
806
- // so the file history is preserved even on partial DB failures.
807
- // Files = source of truth; DB updates are best-effort.
808
- //
809
- // IMPORTANT: snapshot failures must be loud — they are NOT counted in
810
- // the entity `failed` counter, so without explicit surfacing here and
811
- // a thrown CLIError at the end of `_push`, a snapshot failure would
812
- // be hidden behind the "All N change(s) pushed successfully" summary
813
- // and the process would exit 0.
814
- // Retry policy: snapshot upload is the source-of-truth step; transient
815
- // failures (network blips, 5xx, timeouts) are common with multipart
816
- // uploads, so we retry up to 3 times with exponential backoff (1s, 2s,
817
- // 4s) before giving up. 4xx responses are still retried — keeping the
818
- // policy dumb here mirrors the single retry already in the api-client
819
- // layer for entity pushes.
820
- let snapshotSucceeded = false;
821
- let snapshotError;
822
- let snapshotAttempts = 0;
823
- if (prePushDraft) {
824
- const SNAPSHOT_MAX_ATTEMPTS = 4;
825
- const SNAPSHOT_RETRY_BASE_MS = 1000;
826
- const snapshotTracer = getTracer();
827
- for (let attempt = 1; attempt <= SNAPSHOT_MAX_ATTEMPTS; attempt++) {
828
- snapshotAttempts = attempt;
829
- const label = attempt === 1
830
- ? "Pushing snapshot (file state)..."
831
- : `Retrying snapshot push (attempt ${attempt}/${SNAPSHOT_MAX_ATTEMPTS})...`;
832
- if (json)
833
- stderrLog(label);
834
- const snapshotSpinner = json ? null : startSpinner(label);
835
- // Per-attempt span — each snapshot upload is a network call that can
836
- // hang for minutes (large multipart upload). Surfacing each attempt
837
- // separately in Logfire lets us see retry behavior, attempt latency,
838
- // and which attempt finally succeeded. Logfire query:
839
- // `name:"aui.push.task.snapshot" AND attributes."snapshot.attempt":3`
840
- // finds every push that needed a third try.
841
- let attemptError;
842
- const attemptResolved = await snapshotTracer.startActiveSpan("aui.push.task.snapshot", async (snapSpan) => {
843
- snapSpan.setAttribute("push.task.type", "snapshot");
844
- snapSpan.setAttribute("push.task.label", label);
845
- snapSpan.setAttribute("snapshot.attempt", attempt);
846
- snapSpan.setAttribute("snapshot.max_attempts", SNAPSHOT_MAX_ATTEMPTS);
847
- snapSpan.setAttribute("snapshot.file_count", fileData.length);
848
- snapSpan.setAttribute("snapshot.agent_id", prePushDraft.agentId);
849
- snapSpan.setAttribute("snapshot.version_id", prePushDraft.versionId);
850
- try {
851
- const snapshotResult = await pushSnapshot(client, prePushDraft.agentId, prePushDraft.versionId, projectRoot, fileData);
852
- if (snapshotResult.success) {
853
- snapSpan.setStatus({ code: SpanStatusCode.OK });
854
- snapSpan.setAttribute("snapshot.outcome", "success");
855
- return { ok: true, error: undefined };
856
- }
857
- const errMsg = snapshotResult.error || "Unknown snapshot error";
858
- snapSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
859
- snapSpan.setAttribute("snapshot.outcome", "failed");
860
- snapSpan.setAttribute("push.task.error", errMsg);
861
- if (attempt < SNAPSHOT_MAX_ATTEMPTS) {
862
- snapSpan.addEvent("snapshot.retry_will_follow", {
863
- next_attempt: attempt + 1,
864
- backoff_ms: SNAPSHOT_RETRY_BASE_MS * Math.pow(2, attempt - 1),
865
- });
866
- }
867
- return { ok: false, error: errMsg };
595
+ if (result.data?.gcs_key) {
596
+ pSpan.setAttribute("push.bundle.gcs_key", result.data.gcs_key);
868
597
  }
869
- catch (error) {
870
- const errMsg = error instanceof Error ? error.message : String(error);
871
- snapSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
872
- snapSpan.recordException(error instanceof Error ? error : new Error(errMsg));
873
- snapSpan.setAttribute("snapshot.outcome", "exception");
874
- snapSpan.setAttribute("push.task.error", errMsg);
875
- return { ok: false, error: errMsg };
598
+ if (result.data?.sha256) {
599
+ pSpan.setAttribute("push.bundle.sha256", result.data.sha256);
876
600
  }
877
- finally {
878
- snapSpan.end();
601
+ if (typeof result.data?.size === "number") {
602
+ pSpan.setAttribute("push.bundle.size_bytes", result.data.size);
879
603
  }
880
- });
881
- if (attemptResolved.ok) {
882
- const okMsg = attempt === 1
883
- ? `Snapshot pushed (${fileData.length} file(s))`
884
- : `Snapshot pushed (${fileData.length} file(s), attempt ${attempt}/${SNAPSHOT_MAX_ATTEMPTS})`;
885
- if (snapshotSpinner)
886
- snapshotSpinner.succeed(okMsg);
887
- else
888
- stderrLog(okMsg);
889
- snapshotSucceeded = true;
890
- snapshotError = undefined;
891
- break;
604
+ return { ok: true, result };
892
605
  }
893
- attemptError = attemptResolved.error;
894
- snapshotError = attemptError;
895
- const isLast = attempt === SNAPSHOT_MAX_ATTEMPTS;
896
- const failMsg = isLast
897
- ? `Snapshot push failed after ${attempt} attempt(s): ${attemptError}`
898
- : `Snapshot push failed (attempt ${attempt}/${SNAPSHOT_MAX_ATTEMPTS}): ${attemptError}`;
899
- if (snapshotSpinner)
900
- snapshotSpinner.fail(failMsg);
901
- else
902
- stderrLog(failMsg);
903
- if (isLast)
904
- break;
905
- const delayMs = SNAPSHOT_RETRY_BASE_MS * Math.pow(2, attempt - 1);
906
- if (json)
907
- stderrLog(`Waiting ${delayMs}ms before retry...`);
908
- await new Promise((resolve) => setTimeout(resolve, delayMs));
606
+ const errMsg = result.error || "Unknown push error";
607
+ pSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
608
+ pSpan.setAttribute("push.bundle.outcome", "failed");
609
+ pSpan.setAttribute("push.task.error", errMsg);
610
+ return { ok: false, error: errMsg, result };
909
611
  }
910
- pushSpan.setAttribute("push.snapshot.success", snapshotSucceeded);
911
- pushSpan.setAttribute("push.snapshot.attempts", snapshotAttempts);
912
- if (!snapshotSucceeded && snapshotError) {
913
- pushSpan.setAttribute("push.snapshot.error", snapshotError);
914
- }
915
- }
916
- const snapshotStatus = prePushDraft
917
- ? snapshotSucceeded
918
- ? "succeeded"
919
- : "failed"
920
- : undefined;
921
- const memoryPath = writePushMemory(projectRoot, agentCodeStr, agentIdStr, pushTasks, succeededFiles, pushFailures);
922
- // ─── Baseline Update ───
923
- // Only commit baseline if snapshot succeeded (or no draft = legacy mode).
924
- // This ensures: if snapshot fails, user re-runs `aui push` to retry both
925
- // failed entity pushes AND the snapshot. Local files remain the source
926
- // of truth until the server has captured them.
927
- //
928
- // CRITICAL (CTS-12340 follow-up): when one file has BOTH succeeded and
929
- // failed tasks (e.g. integrations.aui.json with a successful DELETE on
930
- // web-search and a failed POST on search-flights), do NOT commit that
931
- // file to baseline. If we did, the next push's git diff would treat the
932
- // failed item as "already on the platform" and emit a PATCH that 404s.
933
- // The previous behaviour stuck users in an unrecoverable retry loop.
934
- let baselineUpdated = false;
935
- const canCommitBaseline = !prePushDraft || snapshotSucceeded;
936
- if (canCommitBaseline) {
937
- // A file is committable iff EVERY task that targeted it succeeded.
938
- // Build the failed-files set from `pushFailures` (which now includes
939
- // both agent-settings entity failures AND knowledge-hub failures —
940
- // see the KB push step).
941
- const failedFiles = new Set();
942
- for (const f of pushFailures) {
943
- if (f.file)
944
- failedFiles.add(f.file);
945
- }
946
- const filesSafeToCommit = succeededFiles.filter((f) => !failedFiles.has(f));
947
- if (failed > 0 && succeeded > 0) {
948
- if (filesSafeToCommit.length > 0) {
949
- commitBaselineFiles(projectRoot, filesSafeToCommit, `pushed ${succeeded} change(s) (${failedFiles.size} file(s) held back due to per-task failures)`);
950
- baselineUpdated = true;
951
- pushSpan.addEvent("baseline.partial_commit", {
952
- committed_files: filesSafeToCommit.length,
953
- held_back_files: failedFiles.size,
954
- });
955
- }
956
- else {
957
- pushSpan.addEvent("baseline.fully_held_back", {
958
- failed_files: failedFiles.size,
612
+ catch (error) {
613
+ const errMsg = error instanceof Error ? error.message : String(error);
614
+ pSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
615
+ pSpan.recordException(error instanceof Error ? error : new Error(errMsg));
616
+ pSpan.setAttribute("push.bundle.outcome", "exception");
617
+ pSpan.setAttribute("push.task.error", errMsg);
618
+ // Server changed the agent's mode mid-flight (rare: usually
619
+ // an admin flipping `bundle_mode` while a push is in
620
+ // progress). The 422 is unrecoverable on this surface —
621
+ // translate to a clear "rerun" hint instead of letting it
622
+ // look like a transient push error.
623
+ if (isModeMismatchError(error)) {
624
+ pSpan.setAttribute("push.bundle.mode_mismatch", true);
625
+ throw new CLIError(`Server reports this agent is now in DB-records mode the JSON push endpoint rejected the request (422).`, {
626
+ suggestion: "Re-run `aui push`. The CLI re-detects the agent's mode on every invocation and will pick the legacy per-entity pipeline on the next run.",
959
627
  });
960
628
  }
629
+ return { ok: false, error: errMsg, result: null };
961
630
  }
962
- else if (failed === 0) {
963
- commitBaseline(projectRoot, "pushed changes");
964
- baselineUpdated = true;
965
- pushSpan.addEvent("baseline.full_commit");
631
+ finally {
632
+ pSpan.end();
966
633
  }
634
+ });
635
+ if (attemptOutcome.ok) {
636
+ const newTag = attemptOutcome.result.data?.new_version_tag ?? prePushDraft.label;
637
+ const okMsg = attempt === 1
638
+ ? `Push complete → ${newTag} (bundle: ${bundleEntityCount} item(s))`
639
+ : `Push complete → ${newTag} (bundle: ${bundleEntityCount} item(s), attempt ${attempt}/${PUSH_MAX_ATTEMPTS})`;
640
+ if (attemptSpinner)
641
+ attemptSpinner.succeed(okMsg);
642
+ else
643
+ stderrLog(okMsg);
644
+ pushResult = attemptOutcome.result;
645
+ lastError = undefined;
646
+ break;
647
+ }
648
+ lastError = attemptOutcome.error;
649
+ // 4xx are deterministic; only retry 5xx + 409 (lock contention).
650
+ const shouldRetry = isTransientPushError(attemptOutcome.error) && attempt < PUSH_MAX_ATTEMPTS;
651
+ const isLast = !shouldRetry;
652
+ const failMsg = isLast
653
+ ? `Push failed after ${attempt} attempt(s): ${attemptOutcome.error}`
654
+ : `Push failed (attempt ${attempt}/${PUSH_MAX_ATTEMPTS}): ${attemptOutcome.error}`;
655
+ if (attemptSpinner)
656
+ attemptSpinner.fail(failMsg);
657
+ else
658
+ stderrLog(failMsg);
659
+ if (isLast) {
660
+ pushResult = attemptOutcome.result;
661
+ break;
967
662
  }
968
- else {
969
- pushSpan.addEvent("baseline.skipped_due_to_snapshot_failure");
970
- }
971
- pushSpan.setAttribute("push.baseline_updated", baselineUpdated);
972
- log(_jsx(PushFinalSummary, { succeeded: succeeded, failed: failed, baselineUpdated: baselineUpdated, logDir: logRelPath, memoryPath: memoryPath, snapshotStatus: snapshotStatus, snapshotError: snapshotError }));
973
- if (failed > 0) {
974
- log(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StatusLine, { kind: "warning", label: `${failed} entity change(s) failed to push to DB.` }), pushFailures.map((f) => (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { color: "red", children: [" ", icons.error, " ", f.label] }), _jsxs(Text, { color: colors.muted, children: [" Error: ", f.error] }), f.file && _jsxs(Text, { color: colors.muted, children: [" File: ", f.file] })] }, f.label))), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: colors.info, bold: true, children: "What to do next: " }), _jsxs(Text, { color: colors.muted, children: ["Fix the issues above and re-run ", _jsx(Text, { bold: true, children: "aui push" }), " to retry the failed changes."] })] })] }));
975
- }
976
- // ─── Snapshot result message ───
977
- if (prePushDraft) {
978
- if (!snapshotSucceeded) {
979
- log(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StatusLine, { kind: "error", label: `Snapshot upload failed${snapshotError ? `: ${snapshotError}` : ""}` }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: colors.info, bold: true, children: "What to do next: " }), _jsxs(Text, { color: colors.muted, children: ["Re-run ", _jsx(Text, { bold: true, children: "aui push" }), " to retry the snapshot. Your local files are the source of truth \u2014 they remain unchanged."] })] })] }));
980
- }
981
- else if (failed === 0) {
982
- log(_jsx(StatusLine, { kind: "info", label: `All changes pushed into draft ${prePushDraft.label}. Run: aui version publish → aui version activate` }));
663
+ const delayMs = PUSH_RETRY_BASE_MS * Math.pow(2, attempt - 1);
664
+ if (json)
665
+ stderrLog(`Waiting ${delayMs}ms before retry...`);
666
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
667
+ }
668
+ const pushSucceeded = !!pushResult && pushResult.success;
669
+ pushSpan.setAttribute("push.bundle.success", pushSucceeded);
670
+ pushSpan.setAttribute("push.bundle.attempts", pushAttempts);
671
+ if (!pushSucceeded && lastError) {
672
+ pushSpan.setAttribute("push.bundle.error", lastError);
673
+ pushFailures.push({
674
+ label: `Push bundle to ${prePushDraft.label}`,
675
+ error: lastError,
676
+ });
677
+ }
678
+ // ─── Persist new revision tag to .auirc ───
679
+ // The backend assigns a fresh `version_tag` on every push (e.g.
680
+ // v8.13 → v8.14). Save it to `version_tag` (NOT `version_label`,
681
+ // which represents the version document's `version_number` — e.g.
682
+ // "v8" — and is used for telemetry / version UX). On the next
683
+ // `aui pull` we use this tag as the default `?version_tag=…` query
684
+ // param so users re-pull exactly the revision they just pushed.
685
+ if (pushSucceeded && pushResult?.data) {
686
+ const newTag = pushResult.data.new_version_tag;
687
+ if (newTag) {
688
+ try {
689
+ saveProjectConfig({ ...projectConfig, version_tag: newTag }, projectRoot);
690
+ pushSpan.addEvent("auirc.version_tag_persisted", { tag: newTag });
983
691
  }
984
- else {
985
- log(_jsx(StatusLine, { kind: "warning", label: `Snapshot saved for draft ${prePushDraft.label}, but ${failed} DB update(s) failed. Re-run "aui push" to retry the DB updates before publishing.` }));
692
+ catch (err) {
693
+ if (process.env.AUI_DEBUG) {
694
+ console.warn("[debug] failed to persist version_tag to .auirc:", err instanceof Error ? err.message : err);
695
+ }
986
696
  }
987
697
  }
988
- const snapshotFailed = snapshotStatus === "failed";
989
- pushSpan.setAttribute("push.exit_reason", failed > 0
990
- ? succeeded > 0
991
- ? "partial_failure"
992
- : "failed"
993
- : snapshotFailed
994
- ? "snapshot_failed"
995
- : "completed");
996
- pushSpan.setAttribute("push.succeeded_count", succeeded);
997
- pushSpan.setAttribute("push.failed_count", failed);
998
- // ─── Final structured JSON envelope (success path) ───
999
- // The BFF / CI consumes --json output programmatically. On success we
1000
- // emit a single envelope at the very end so the caller can parse one
1001
- // top-level JSON document with the full push outcome. Failure paths
1002
- // are handled by handleError() via outputJsonError().
1003
- if (json && !snapshotFailed && failed === 0) {
1004
- outputJson({
1005
- agent: {
1006
- code: agentCodeStr,
1007
- id: agentIdStr,
1008
- },
1009
- version: prePushDraft
1010
- ? { id: prePushDraft.versionId, label: prePushDraft.label }
1011
- : undefined,
1012
- succeeded_count: succeeded,
1013
- failed_count: 0,
1014
- succeeded_files: succeededFiles,
1015
- snapshot: snapshotStatus
1016
- ? {
1017
- status: snapshotStatus,
1018
- attempts: snapshotAttempts,
1019
- }
1020
- : undefined,
1021
- baseline_updated: baselineUpdated,
1022
- log_dir: logRelPath,
1023
- memory_path: memoryPath,
1024
- });
1025
- }
1026
- // ─── Failure throws (loud + non-zero exit + JSON error envelope) ───
1027
- // Snapshot failure takes priority because if the snapshot doesn't land
1028
- // the version's file history is incomplete, even when individual DB
1029
- // updates succeeded. Falls through to handleError() which prints the
1030
- // formatted error and exits non-zero.
1031
- if (snapshotFailed) {
1032
- throw new CLIError(`Snapshot upload failed${snapshotError ? `: ${snapshotError}` : ""}`, {
1033
- suggestion: "Re-run `aui push` to retry the snapshot. Your local files are the source of truth — they remain unchanged.",
1034
- });
1035
- }
1036
- // Entity-task partial / total failure. Without this throw, `aui push`
1037
- // would print "X failed" to stdout and exit 0 — the BFF would have no
1038
- // way to know the push didn't fully apply, and Logfire would record a
1039
- // "completed" span — a real reported failure mode.
1040
- if (failed > 0) {
1041
- const labels = pushFailures.map((f) => f.label).join(", ");
1042
- throw new CLIError(`${failed} entity push task(s) failed: ${labels}`, {
1043
- suggestion: succeeded > 0
1044
- ? `${succeeded} change(s) were saved. Fix the errors above and re-run \`aui push\` to retry the rest.`
1045
- : "Fix the errors above and re-run `aui push`.",
1046
- });
1047
- }
1048
698
  }
1049
- catch (error) {
1050
- if (spinner)
1051
- spinner.fail("Push failed");
1052
- // CLIErrors carry actionable info and a meaningful exit code let
1053
- // handleError() format them and exit non-zero. Without this re-throw
1054
- // a snapshot-failure CLIError thrown above would be swallowed in TUI
1055
- // mode and the process would exit 0.
1056
- if (error instanceof CLIError) {
1057
- throw error;
1058
- }
1059
- if (!json)
1060
- log(_jsx(ErrorDisplay, { error: error }));
1061
- else
1062
- throw error;
699
+ // ─── Baseline commit ───
700
+ // The new endpoint is atomic for the bundle, so we either commit
701
+ // everything (bundle pushed AND KBs OK) or hold the baseline (so the
702
+ // user can re-run `aui push` to retry against the same pre-push state).
703
+ let baselineUpdated = false;
704
+ if (pushSucceeded && kbFailedCount === 0) {
705
+ commitBaseline(projectRoot, `pushed bundle ${pushResult?.data?.new_version_tag ?? prePushDraft.label}`);
706
+ baselineUpdated = true;
707
+ pushSpan.addEvent("baseline.full_commit");
708
+ }
709
+ else if (pushSucceeded && kbFailedCount > 0) {
710
+ pushSpan.addEvent("baseline.held_back_due_to_kb_failure", {
711
+ kb_failed: kbFailedCount,
712
+ });
713
+ }
714
+ else {
715
+ pushSpan.addEvent("baseline.held_back_due_to_push_failure");
716
+ }
717
+ pushSpan.setAttribute("push.baseline_updated", baselineUpdated);
718
+ // ─── Push memory + final summary ───
719
+ const logRelPath = path.relative(projectRoot, pushLogDir);
720
+ const agentCodeStr = projectConfig.agent_code || projectConfig.agent_id || "unknown";
721
+ const agentIdStr = projectConfig.agent_id || "unknown";
722
+ const memoryPath = writePushMemory(projectRoot, {
723
+ agentCode: agentCodeStr,
724
+ agentId: agentIdStr,
725
+ versionLabel: prePushDraft.label,
726
+ newVersionTag: pushResult?.data?.new_version_tag,
727
+ revisionId: pushResult?.data?.revision_id,
728
+ commitMessage: pushResult?.data?.commit_message ?? options.commitMessage ?? undefined,
729
+ fileData,
730
+ // The new endpoint is bundle-shaped, so there isn't really a
731
+ // per-file upload list anymore — record the local file paths the
732
+ // bundle was assembled from instead. Diagnostic only.
733
+ uploadedFiles: fileData.map((fd) => fd.file),
734
+ diff,
735
+ pushSucceeded,
736
+ bundleError: pushSucceeded ? undefined : lastError,
737
+ kbFailures: kbResult.failures,
738
+ });
739
+ log(_jsx(PushFinalSummary, { succeeded: pushSucceeded ? bundleEntityCount : 0, failed: pushFailures.length, baselineUpdated: baselineUpdated, logDir: logRelPath, memoryPath: memoryPath, snapshotStatus: pushSucceeded ? "succeeded" : "failed", snapshotError: lastError }));
740
+ if (pushFailures.length > 0) {
741
+ log(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StatusLine, { kind: "warning", label: `${pushFailures.length} task(s) failed.` }), pushFailures.map((f) => (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Text, { color: "red", children: [" ", icons.error, " ", f.label] }), _jsxs(Text, { color: colors.muted, children: [" Error: ", f.error] }), f.file && _jsxs(Text, { color: colors.muted, children: [" File: ", f.file] })] }, f.label))), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: colors.info, bold: true, children: "What to do next: " }), _jsxs(Text, { color: colors.muted, children: ["Fix the issues above and re-run ", _jsx(Text, { bold: true, children: "aui push" }), " to retry. Your local files remain the source of truth."] })] })] }));
742
+ }
743
+ else if (pushSucceeded) {
744
+ const newTag = pushResult?.data?.new_version_tag ?? prePushDraft.label;
745
+ log(_jsx(StatusLine, { kind: "info", label: `All changes pushed to draft ${newTag}. Run: aui version publish → aui version activate` }));
746
+ }
747
+ // ─── Telemetry exit reason ───
748
+ pushSpan.setAttribute("push.exit_reason", pushSucceeded
749
+ ? kbFailedCount > 0
750
+ ? "partial_kb_failure"
751
+ : "completed"
752
+ : "failed");
753
+ pushSpan.setAttribute("push.succeeded_count", pushSucceeded ? bundleEntityCount : 0);
754
+ pushSpan.setAttribute("push.failed_count", pushFailures.length);
755
+ if (pushFailures.length > 0) {
756
+ pushSpan.setAttribute("push.failures", JSON.stringify(pushFailures));
757
+ }
758
+ // ─── JSON envelope (success path) ───
759
+ if (json && pushSucceeded && kbFailedCount === 0) {
760
+ outputJson({
761
+ agent: { code: agentCodeStr, id: agentIdStr },
762
+ version: {
763
+ id: prePushDraft.versionId,
764
+ label: prePushDraft.label,
765
+ new_tag: pushResult?.data?.new_version_tag,
766
+ },
767
+ bundle: {
768
+ status: "succeeded",
769
+ attempts: pushAttempts,
770
+ // No more per-file upload list — the new endpoint writes one
771
+ // canonical `bundle.json` and returns its key + hash + size.
772
+ item_count: bundleEntityCount,
773
+ item_counts: bundleSummary,
774
+ new_version_tag: pushResult?.data?.new_version_tag,
775
+ new_version_revision_number: pushResult?.data?.new_version_revision_number,
776
+ revision_id: pushResult?.data?.revision_id,
777
+ commit_message: pushResult?.data?.commit_message,
778
+ gcs_key: pushResult?.data?.gcs_key,
779
+ sha256: pushResult?.data?.sha256,
780
+ size_bytes: pushResult?.data?.size,
781
+ created_at: pushResult?.data?.created_at,
782
+ },
783
+ baseline_updated: baselineUpdated,
784
+ log_dir: logRelPath,
785
+ memory_path: memoryPath,
786
+ });
1063
787
  }
788
+ // ─── Failure throws (loud + non-zero exit + JSON error envelope) ───
789
+ // CLIError funnels into handleError() which prints the formatted error,
790
+ // emits the JSON error envelope (in --json mode), and exits non-zero.
791
+ // The BFF parses both signals (HTTP status + JSON envelope) so a silent
792
+ // exit-0 here would let `agent-builder-bff` claim a phantom success.
793
+ if (!pushSucceeded) {
794
+ throw new CLIError(`Push failed: ${lastError ?? "unknown error"}`, {
795
+ suggestion: "Fix the errors above and re-run `aui push`. Your local files remain the source of truth.",
796
+ });
797
+ }
798
+ if (kbFailedCount > 0) {
799
+ const labels = pushFailures.map((f) => f.label).join(", ");
800
+ throw new CLIError(`${kbFailedCount} knowledge-base task(s) failed: ${labels}`, {
801
+ suggestion: "Re-run `aui push` to retry KB uploads. The .aui.json bundle has been saved.",
802
+ });
803
+ }
804
+ }
805
+ // ─── Transient error classifier (retry decision) ───
806
+ //
807
+ // The new push endpoint surfaces failures as `{ success: false, error: "<status>: <msg>" }`
808
+ // or as a thrown error with the same shape. We retry 5xx (network /
809
+ // backend hiccup) and 409 (concurrent push lock contention) — both are
810
+ // expected to resolve on backoff. 4xx (other than 409) are deterministic
811
+ // — validation / auth / missing-resource — and never get retried.
812
+ function isTransientPushError(err) {
813
+ if (!err)
814
+ return false;
815
+ // Errors come back as "<status>: <body>" from the api client, or as
816
+ // raw fetch errors / timeouts (no leading status). Treat fetch/abort
817
+ // errors as transient too — they're not the user's fault.
818
+ const m = err.match(/^(\d{3})/);
819
+ if (m) {
820
+ const status = parseInt(m[1], 10);
821
+ if (status === 409)
822
+ return true;
823
+ if (status >= 500 && status <= 599)
824
+ return true;
825
+ return false;
826
+ }
827
+ // No HTTP status → likely network/timeout. Retry.
828
+ return /timeout|timed out|fetch failed|network|econnreset|enotfound|eai_again|socket hang up/i.test(err);
1064
829
  }
1065
830
  /**
1066
831
  * Lookup the agent-management record for the current `.auirc` project
1067
- * (preferred) or the active session fallback same precedence as draft
1068
- * resolution. Each attempt records its error so callers can surface the full
1069
- * picture instead of silently dropping `agent_id` from request bodies.
832
+ * (preferred) or the active session fallback. Each attempt records its
833
+ * error so callers can surface the full picture instead of silently
834
+ * dropping `agent_id` from request bodies.
1070
835
  */
1071
836
  async function lookupAgentManagementInfoForPush(config, projectConfig, session) {
1072
837
  const client = new AUIClient({
@@ -1082,9 +847,24 @@ async function lookupAgentManagementInfoForPush(config, projectConfig, session)
1082
847
  let agentInfo;
1083
848
  const errors = [];
1084
849
  const agentMgmtId = session.agent_management_id;
850
+ const projectAgentMgmtId = projectConfig.agent_management_id;
1085
851
  const projectNetworkId = projectConfig.agent_id;
1086
852
  const fallbackNetworkId = session.network_id;
1087
- if (projectNetworkId) {
853
+ // Highest-priority shortcut: if `.auirc` (or the `--agent-id`
854
+ // overlay in `_push`) already carries an `agent_management_id`,
855
+ // hit `getAgent` directly. One round-trip, no list scan.
856
+ if (projectAgentMgmtId) {
857
+ try {
858
+ agentInfo = await client.agentManagement.getAgent(projectAgentMgmtId);
859
+ }
860
+ catch (err) {
861
+ errors.push(`getAgent(${projectAgentMgmtId}) threw: ${err instanceof Error ? err.message : String(err)}`);
862
+ if (process.env.AUI_DEBUG) {
863
+ console.warn(`[debug] lookupAgentManagementInfoForPush: getAgent(${projectAgentMgmtId}) failed (stale id?):`, err instanceof Error ? err.message : err);
864
+ }
865
+ }
866
+ }
867
+ if (!agentInfo && projectNetworkId) {
1088
868
  try {
1089
869
  const resp = await client.agentManagement.listAgents(client.getOrganizationId(), 1, 50, { network_id: projectNetworkId });
1090
870
  agentInfo = resp.items.find((a) => a.scope.network_id === projectNetworkId ||
@@ -1094,18 +874,14 @@ async function lookupAgentManagementInfoForPush(config, projectConfig, session)
1094
874
  }
1095
875
  }
1096
876
  catch (err) {
1097
- // Accumulate into `errors` so the eventual ConfigError can list every
1098
- // resolution path that failed (alboim's a117251). Also emit AUI_DEBUG
1099
- // warning for live operator observability (zero silent errors policy).
1100
877
  errors.push(`listAgents(network_id=${projectNetworkId}) threw: ${err instanceof Error ? err.message : String(err)}`);
1101
878
  if (process.env.AUI_DEBUG) {
1102
879
  console.warn(`[debug] resolveVersionDraft: listAgents(network_id=${projectNetworkId}) failed:`, err instanceof Error ? err.message : err);
1103
880
  }
1104
881
  }
1105
882
  }
1106
- // Try the session's agent_management_id even when the project has a network
1107
- // id it's a direct getAgent call, no list scan, and it gracefully covers
1108
- // the case where listAgents fell through above.
883
+ // Direct getAgent call by session's agent_management_id no list scan,
884
+ // and gracefully covers the case where listAgents fell through.
1109
885
  if (!agentInfo && agentMgmtId) {
1110
886
  try {
1111
887
  agentInfo = await client.agentManagement.getAgent(agentMgmtId);
@@ -1117,7 +893,9 @@ async function lookupAgentManagementInfoForPush(config, projectConfig, session)
1117
893
  }
1118
894
  }
1119
895
  }
1120
- if (!agentInfo && fallbackNetworkId && fallbackNetworkId !== projectNetworkId) {
896
+ if (!agentInfo &&
897
+ fallbackNetworkId &&
898
+ fallbackNetworkId !== projectNetworkId) {
1121
899
  try {
1122
900
  const resp = await client.agentManagement.listAgents(client.getOrganizationId(), 1, 50, { network_id: fallbackNetworkId });
1123
901
  agentInfo = resp.items.find((a) => a.scope.network_id === fallbackNetworkId ||
@@ -1136,37 +914,83 @@ async function lookupAgentManagementInfoForPush(config, projectConfig, session)
1136
914
  return { agentInfo, errors };
1137
915
  }
1138
916
  /**
1139
- * Return the agent-management UUID to send as `agent_id` on agent-settings
1140
- * write bodies. Reads `.auirc` first; falls back to `lookupAgentManagementInfoForPush`
1141
- * and **persists** the resolved id back to `.auirc` so subsequent pushes don't
1142
- * pay the lookup cost. Throws `ConfigError` if no id can be resolved — never
1143
- * silently returns undefined, because that's how entities ended up in the DB
1144
- * without `agent_id`.
917
+ * Dual-shape resolve for `--agent-id <id>` on `aui push`. Same pattern
918
+ * as `aui import`'s explicit-id branch: the user might pass either an
919
+ * `agent_management_id` (preferred, new world) or a legacy `network_id`
920
+ * (back-compat). Same regex, different document family. Try
921
+ * `agentManagement.getAgent(id)` first, fall back to
922
+ * `listAgents(network_id=id)` on 404. Non-404 errors bubble.
1145
923
  */
1146
- async function resolvePushAgentManagementId(config, projectConfig, session, projectRoot) {
1147
- if (projectConfig.agent_management_id)
1148
- return projectConfig.agent_management_id;
1149
- const { agentInfo, errors } = await lookupAgentManagementInfoForPush(config, projectConfig, session);
1150
- if (!agentInfo) {
1151
- const detail = errors.length > 0 ? `\n - ${errors.join("\n - ")}` : "";
1152
- throw new ConfigError(`Could not resolve agent-management id for this project.${detail}`, {
1153
- suggestion: "Re-run `aui import-agent` (will populate .auirc.agent_management_id) or `aui pull` to back-fill it.",
1154
- });
924
+ async function resolveExplicitAgentForPush(client, explicitId) {
925
+ // 1) Treat as agent_management_id.
926
+ try {
927
+ const info = await client.agentManagement.getAgent(explicitId);
928
+ // Bias the client scope toward the agent's own org/account so the
929
+ // subsequent listVersions / push calls don't 403 against the
930
+ // session's default scope.
931
+ const scopeUpdate = {};
932
+ if (info.scope?.account_id)
933
+ scopeUpdate.accountId = info.scope.account_id;
934
+ if (info.scope?.organization_id)
935
+ scopeUpdate.organizationId = info.scope.organization_id;
936
+ if (scopeUpdate.accountId || scopeUpdate.organizationId) {
937
+ client.setScope(scopeUpdate);
938
+ }
939
+ return {
940
+ agentManagementId: info.id,
941
+ networkId: info.scope?.network_id ?? undefined,
942
+ via: "agent_management_id",
943
+ };
944
+ }
945
+ catch (err) {
946
+ const status = err instanceof AUIAPIError
947
+ ? err.status
948
+ : (err.statusCode ??
949
+ err.status);
950
+ if (status !== 404) {
951
+ throw new CLIError(`Could not resolve --agent-id ${explicitId} as agent_management_id: ${err instanceof Error ? err.message : String(err)}`, {
952
+ suggestion: "Check your auth (`aui status`) and the agent id. If the id is a legacy network_id, it will be tried as a fallback only on 404 from agent-management.",
953
+ cause: err,
954
+ });
955
+ }
956
+ if (process.env.AUI_DEBUG) {
957
+ console.log(`[debug] resolveExplicitAgentForPush: getAgent(${explicitId}) → 404, trying as network_id`);
958
+ }
1155
959
  }
1156
- // Migrate legacy projects: persist back so the next push skips the lookup.
960
+ // 2) Fall back: treat as legacy network_id.
1157
961
  try {
1158
- saveProjectConfig({ ...projectConfig, agent_management_id: agentInfo.id }, projectRoot);
962
+ const resp = await client.agentManagement.listAgents(client.getOrganizationId(), 1, 50, { network_id: explicitId });
963
+ const match = resp.items.find((a) => a.scope.network_id === explicitId || a.id === explicitId);
964
+ if (!match) {
965
+ throw new CLIError(`Could not resolve --agent-id ${explicitId} as agent_management_id OR network_id.`, {
966
+ suggestion: "Verify the agent exists in agent-management. Run `aui agents` to list available agents.",
967
+ });
968
+ }
969
+ const scopeUpdate = {};
970
+ if (match.scope?.account_id)
971
+ scopeUpdate.accountId = match.scope.account_id;
972
+ if (match.scope?.organization_id)
973
+ scopeUpdate.organizationId = match.scope.organization_id;
974
+ if (scopeUpdate.accountId || scopeUpdate.organizationId) {
975
+ client.setScope(scopeUpdate);
976
+ }
977
+ return {
978
+ agentManagementId: match.id,
979
+ networkId: match.scope?.network_id ?? undefined,
980
+ via: "network_id",
981
+ };
1159
982
  }
1160
- catch {
1161
- // .auirc write failure is non-fatal — we already have the id in memory.
983
+ catch (err) {
984
+ if (err instanceof CLIError)
985
+ throw err;
986
+ throw new CLIError(`Failed to resolve --agent-id ${explicitId} via legacy network_id lookup: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
1162
987
  }
1163
- return agentInfo.id;
1164
988
  }
1165
989
  async function resolveVersionDraft(config, projectConfig, session, explicitVersionId) {
1166
990
  // Every error path below MUST throw a typed CLIError (not return null).
1167
- // Returning null silently exits the CLI with code 0 — the BFF then thinks
1168
- // the push succeeded when nothing actually happened, and the failure
1169
- // never reaches Logfire because no exception bubbled to handleError.
991
+ // Returning null silently exits with code 0 — the BFF then thinks the
992
+ // push succeeded and the failure never reaches Logfire because no
993
+ // exception bubbled to handleError.
1170
994
  const { agentInfo, errors: lookupErrors } = await lookupAgentManagementInfoForPush(config, projectConfig, session);
1171
995
  if (!agentInfo) {
1172
996
  const detail = lookupErrors.length > 0 ? `\n - ${lookupErrors.join("\n - ")}` : "";
@@ -1184,17 +1008,88 @@ async function resolveVersionDraft(config, projectConfig, session, explicitVersi
1184
1008
  const key = loadAgentSettingsApiKey();
1185
1009
  if (key)
1186
1010
  client.setAgentSettingsApiKey(key);
1187
- // If user passed --version-id, validate it's a draft
1011
+ // If user passed --version-id, validate it's a draft.
1188
1012
  if (explicitVersionId) {
1189
1013
  let ver;
1014
+ let owningAgent = agentInfo;
1190
1015
  try {
1191
1016
  ver = await client.agentManagement.getVersion(agentInfo.id, explicitVersionId);
1192
1017
  }
1193
1018
  catch (error) {
1194
- throw new CLIError(`Could not fetch version "${explicitVersionId}": ${error instanceof Error ? error.message : String(error)}`, {
1195
- suggestion: "Check the version ID with `aui version list` and try again.",
1196
- cause: error,
1197
- });
1019
+ const status = error.statusCode ??
1020
+ error.status;
1021
+ if (status !== 404) {
1022
+ throw new CLIError(`Could not fetch version "${explicitVersionId}": ${error instanceof Error ? error.message : String(error)}`, {
1023
+ suggestion: "Check the version ID with `aui version list` and try again.",
1024
+ cause: error,
1025
+ });
1026
+ }
1027
+ // ─── Multi-agent fallback (same pattern as `selectVersionForAgent`)
1028
+ //
1029
+ // One `network_id` can map to multiple agent-management records
1030
+ // (data-migration duplicates, account-scoped variants, etc.).
1031
+ // We picked the first match from `lookupAgentManagementInfoForPush`
1032
+ // above, but `--version-id` may belong to a SIBLING agent record
1033
+ // for the same network. Paginate every candidate for the
1034
+ // network and try `getVersion` on each — return the first one
1035
+ // that succeeds with the matching version, and use THAT agent's
1036
+ // id when pushing so the bundle lands on the correct revision.
1037
+ const projectNetworkId = projectConfig.agent_id;
1038
+ if (projectNetworkId) {
1039
+ const probeClient = new AUIClient({
1040
+ baseUrl: config.apiUrl,
1041
+ authToken: config.authToken,
1042
+ accountId: config.accountId,
1043
+ organizationId: config.organizationId,
1044
+ environment: config.environment,
1045
+ });
1046
+ const probeKey = loadAgentSettingsApiKey();
1047
+ if (probeKey)
1048
+ probeClient.setAgentSettingsApiKey(probeKey);
1049
+ const candidates = [];
1050
+ try {
1051
+ let probePage = 1;
1052
+ let probeHasMore = true;
1053
+ while (probeHasMore) {
1054
+ const resp = await probeClient.agentManagement.listAgents(probeClient.getOrganizationId(), probePage, 50, { network_id: projectNetworkId });
1055
+ candidates.push(...resp.items);
1056
+ probeHasMore = probePage < resp.pages;
1057
+ probePage++;
1058
+ }
1059
+ }
1060
+ catch (probeErr) {
1061
+ if (process.env.AUI_DEBUG) {
1062
+ console.warn(`[debug] resolveVersionDraft multi-agent fallback listAgents failed:`, probeErr instanceof Error ? probeErr.message : probeErr);
1063
+ }
1064
+ }
1065
+ const siblings = candidates.filter((c) => c.id !== agentInfo.id);
1066
+ if (process.env.AUI_DEBUG) {
1067
+ console.log(`[debug] resolveVersionDraft: probing ${siblings.length} sibling agent(s) for version ${explicitVersionId}`);
1068
+ }
1069
+ for (const sibling of siblings) {
1070
+ try {
1071
+ ver = await probeClient.agentManagement.getVersion(sibling.id, explicitVersionId);
1072
+ owningAgent = sibling;
1073
+ if (process.env.AUI_DEBUG) {
1074
+ console.log(`[debug] resolveVersionDraft: version ${explicitVersionId} found on sibling agent ${sibling.id}`);
1075
+ }
1076
+ break;
1077
+ }
1078
+ catch (siblingErr) {
1079
+ const sStatus = siblingErr
1080
+ .statusCode ??
1081
+ siblingErr.status;
1082
+ if (sStatus !== 404)
1083
+ throw siblingErr;
1084
+ }
1085
+ }
1086
+ }
1087
+ if (!ver) {
1088
+ throw new CLIError(`Could not fetch version "${explicitVersionId}" on any agent record for this network.`, {
1089
+ suggestion: "Verify the version ID with `aui version list` or from the Playground's `Versions management` dialog. The version may belong to a different account / organization scope.",
1090
+ cause: error,
1091
+ });
1092
+ }
1198
1093
  }
1199
1094
  if (ver.status !== "draft") {
1200
1095
  throw new ValidationError(`Version v${ver.version_number} is "${ver.status}" — you can only push to a draft version.`, {
@@ -1202,9 +1097,14 @@ async function resolveVersionDraft(config, projectConfig, session, explicitVersi
1202
1097
  });
1203
1098
  }
1204
1099
  const label = `v${ver.version_number}`;
1205
- return { versionId: ver.id, label, agentId: agentInfo.id };
1100
+ return {
1101
+ versionId: ver.id,
1102
+ label,
1103
+ agentId: owningAgent.id,
1104
+ agentInfo: owningAgent,
1105
+ };
1206
1106
  }
1207
- // Resolve from .auirc version_id or auto-detect drafts
1107
+ // Resolve from .auirc version_id or auto-detect drafts.
1208
1108
  let allVersions = [];
1209
1109
  try {
1210
1110
  const versionsResp = await client.agentManagement.listVersions(agentInfo.id, 1, 50);
@@ -1216,7 +1116,6 @@ async function resolveVersionDraft(config, projectConfig, session, explicitVersi
1216
1116
  cause: error,
1217
1117
  });
1218
1118
  }
1219
- // If .auirc has a version_id, validate it's still a draft
1220
1119
  if (projectConfig.version_id) {
1221
1120
  const configVersion = allVersions.find((v) => v.id === projectConfig.version_id);
1222
1121
  if (configVersion) {
@@ -1226,10 +1125,15 @@ async function resolveVersionDraft(config, projectConfig, session, explicitVersi
1226
1125
  });
1227
1126
  }
1228
1127
  const label = `v${configVersion.version_number}`;
1229
- return { versionId: configVersion.id, label, agentId: agentInfo.id };
1128
+ return {
1129
+ versionId: configVersion.id,
1130
+ label,
1131
+ agentId: agentInfo.id,
1132
+ agentInfo,
1133
+ };
1230
1134
  }
1231
1135
  }
1232
- // Auto-detect drafts
1136
+ // Auto-detect drafts.
1233
1137
  const drafts = allVersions.filter((v) => v.status === "draft");
1234
1138
  if (drafts.length === 0) {
1235
1139
  throw new ValidationError("No draft version found — you can only push to a draft version.", {
@@ -1239,139 +1143,27 @@ async function resolveVersionDraft(config, projectConfig, session, explicitVersi
1239
1143
  if (drafts.length === 1) {
1240
1144
  const draft = drafts[0];
1241
1145
  const label = `v${draft.version_number}`;
1242
- return { versionId: draft.id, label, agentId: agentInfo.id };
1146
+ return {
1147
+ versionId: draft.id,
1148
+ label,
1149
+ agentId: agentInfo.id,
1150
+ agentInfo,
1151
+ };
1243
1152
  }
1244
- // Multiple drafts — auto-pick the latest (drafts are sorted DESC by created_at)
1245
- // No interactive prompt: keeps `aui push` non-blocking. Use --version-id to
1246
- // explicitly target a different draft.
1153
+ // Multiple drafts — auto-pick the latest. No interactive prompt
1154
+ // (keeps `aui push` non-blocking). Use --version-id to target a
1155
+ // different draft.
1247
1156
  const latest = drafts[0];
1248
1157
  const latestLabel = `v${latest.version_number}`;
1249
1158
  if (!isJsonMode()) {
1250
1159
  log(_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(StatusLine, { kind: "info", label: `${drafts.length} drafts found — auto-selecting latest: ${latestLabel}` }), _jsxs(Text, { color: colors.muted, children: [" Tip: pass ", _jsxs(Text, { bold: true, children: ["--version-id ", '<id>'] }), " to target a specific draft."] })] }));
1251
1160
  }
1252
- return { versionId: latest.id, label: latestLabel, agentId: agentInfo.id };
1253
- }
1254
- /**
1255
- * Push a snapshot of all local agent files to the server via multipart upload.
1256
- * This captures the current state of the files to maintain version history.
1257
- * Version bump happens automatically on the server when snapshot succeeds.
1258
- *
1259
- * If any files fail, the user must re-run `aui push` to retry.
1260
- */
1261
- async function pushSnapshot(client, agentId, versionId, projectRoot, fileData) {
1262
- const filesToUpload = [];
1263
- for (const fd of fileData) {
1264
- const fullPath = path.join(projectRoot, fd.file);
1265
- if (!fs.existsSync(fullPath))
1266
- continue;
1267
- const content = fs.readFileSync(fullPath);
1268
- filesToUpload.push({
1269
- filePath: fullPath,
1270
- fileName: path.basename(fd.file),
1271
- content,
1272
- });
1273
- }
1274
- if (filesToUpload.length === 0) {
1275
- return { success: true, failed: [] };
1276
- }
1277
- const result = await client.agentManagement.pushSnapshot(agentId, versionId, filesToUpload);
1278
1161
  return {
1279
- success: result.success,
1280
- failed: result.failed,
1281
- error: result.error,
1282
- };
1283
- }
1284
- // ─── Agent Settings Params Resolution ───
1285
- async function resolveAgentSettingsParams(config, projectConfig, session, projectRoot, scopeLevel) {
1286
- // Throws ConfigError on any missing config field — never returns null.
1287
- // See note on resolveVersionDraft for why silent returns are forbidden.
1288
- const networkId = projectConfig.agent_id || session.network_id;
1289
- const accountId = projectConfig.account_id || config.accountId;
1290
- const organizationId = projectConfig.organization_id || config.organizationId;
1291
- const userId = session.user_id;
1292
- if (!networkId || !accountId || !organizationId) {
1293
- const missing = [];
1294
- if (!networkId)
1295
- missing.push("network_id (agent_id in .auirc)");
1296
- if (!accountId)
1297
- missing.push("account_id (in .auirc or session)");
1298
- if (!organizationId)
1299
- missing.push("organization_id (in .auirc or session)");
1300
- throw new ConfigError(`Missing: ${missing.join(", ")}`, {
1301
- suggestion: "Re-import the agent (`aui import`) or re-login (`aui login`).",
1302
- });
1303
- }
1304
- let categoryId = projectConfig.network_category_id;
1305
- if (!categoryId) {
1306
- try {
1307
- const client = new AUIClient({
1308
- baseUrl: config.apiUrl,
1309
- authToken: config.authToken,
1310
- accountId,
1311
- organizationId,
1312
- environment: config.environment,
1313
- });
1314
- const resp = (await client.networks.get(networkId));
1315
- const cat = resp?.data?.category;
1316
- if (cat) {
1317
- categoryId = typeof cat === "string" ? cat : cat._id;
1318
- saveProjectConfig({ ...projectConfig, network_category_id: categoryId }, projectRoot);
1319
- }
1320
- }
1321
- catch (err) {
1322
- // Falls through to the explicit ConfigError below if no categoryId
1323
- // could be resolved. Surface in AUI_DEBUG so the operator can see
1324
- // why the auto-fetch failed instead of just the generic "Missing
1325
- // network_category_id" message.
1326
- if (process.env.AUI_DEBUG) {
1327
- console.warn(`[debug] resolveAgentSettingsParams: networks.get(${networkId}) failed:`, err instanceof Error ? err.message : err);
1328
- }
1329
- }
1330
- }
1331
- if (!categoryId) {
1332
- throw new ConfigError("Missing network_category_id.", {
1333
- suggestion: "Re-import the agent (`aui import`) to fix.",
1334
- });
1335
- }
1336
- const baseParams = {
1337
- updated_by: userId,
1338
- network_id: networkId,
1339
- account_id: accountId,
1340
- organization_id: organizationId,
1341
- network_category_id: categoryId,
1162
+ versionId: latest.id,
1163
+ label: latestLabel,
1164
+ agentId: agentInfo.id,
1165
+ agentInfo,
1342
1166
  };
1343
- if (scopeLevel && scopeLevel !== "network") {
1344
- const scoped = applyScopeLevel({
1345
- network_id: networkId,
1346
- account_id: accountId,
1347
- organization_id: organizationId,
1348
- network_category_id: categoryId,
1349
- }, scopeLevel);
1350
- if (process.env.AUI_DEBUG) {
1351
- console.log(`[debug] push scope params: ${JSON.stringify(scoped)}`);
1352
- }
1353
- return {
1354
- updated_by: userId,
1355
- network_id: scoped.network_id ?? "",
1356
- account_id: scoped.account_id ?? "",
1357
- organization_id: scoped.organization_id ?? "",
1358
- network_category_id: scoped.network_category_id ?? "",
1359
- };
1360
- }
1361
- return baseParams;
1362
- }
1363
- // ─── Push Task Classification Helpers ───
1364
- /**
1365
- * Integration tasks split into two phases by the unified push order:
1366
- * - Upserts (POST/PATCH/PUT) run BEFORE knowledge bases + tools, so KBs
1367
- * can attach to integrations and tools can reference integration codes.
1368
- * - Deletes run AFTER tools / entities, so the last write that mentioned
1369
- * the integration succeeds before the row is removed.
1370
- */
1371
- function isIntegrationUpsertTask(t) {
1372
- return (t.type === "put-integrations" ||
1373
- t.type === "create-integration" ||
1374
- t.type === "patch-integration");
1375
1167
  }
1376
1168
  async function pushKnowledgeHubs(projectRoot, projectConfig) {
1377
1169
  const { getKnowledgeHubChanges } = await import("../utils/git.js");
@@ -1433,7 +1225,7 @@ async function pushKnowledgeHubs(projectRoot, projectConfig) {
1433
1225
  }
1434
1226
  // Per-KB delete in its own span so each one shows up in Logfire as
1435
1227
  // `aui.push.task.kb-delete` with status, kb name, kb id, and error
1436
- // body. Same observability shape as agent-settings entity tasks.
1228
+ // body same observability shape as the previous per-entity tasks.
1437
1229
  const kbDelTracer = getTracer();
1438
1230
  await kbDelTracer.startActiveSpan("aui.push.task.kb-delete", async (span) => {
1439
1231
  span.setAttribute("push.task.type", "kb-delete");
@@ -1459,7 +1251,10 @@ async function pushKnowledgeHubs(projectRoot, projectConfig) {
1459
1251
  }
1460
1252
  catch (delErr) {
1461
1253
  // Per-KB error: count it, keep going so partial work shows up.
1462
- if (isNotFoundError(delErr)) {
1254
+ // 404 → already absent (success); other errors → real failure.
1255
+ const statusCode = delErr
1256
+ .statusCode ?? delErr.status;
1257
+ if (statusCode === 404) {
1463
1258
  span.setStatus({ code: SpanStatusCode.OK });
1464
1259
  span.addEvent("fallback.delete_404_already_absent");
1465
1260
  span.setAttribute("push.task.fallback", "delete_404_already_absent");
@@ -1471,8 +1266,8 @@ async function pushKnowledgeHubs(projectRoot, projectConfig) {
1471
1266
  span.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
1472
1267
  span.recordException(delErr instanceof Error ? delErr : new Error(errMsg));
1473
1268
  span.setAttribute("push.task.error", errMsg);
1474
- if (delErr.statusCode) {
1475
- span.setAttribute("push.task.error_status_code", delErr.statusCode);
1269
+ if (statusCode) {
1270
+ span.setAttribute("push.task.error_status_code", statusCode);
1476
1271
  }
1477
1272
  failures.push({
1478
1273
  label: `Delete knowledge base: ${kbName}`,
@@ -1522,9 +1317,6 @@ async function pushKnowledgeHubs(projectRoot, projectConfig) {
1522
1317
  log(_jsx(Box, { paddingX: 1, children: _jsx(StatusLine, { kind: "warning", label: `Skipped unsupported file: ${path.basename(skipped)} (only .pdf, .md, .txt, .json)` }) }));
1523
1318
  }
1524
1319
  if (supportedFiles.length > 0) {
1525
- // Per-KB upload in its own span — Logfire query
1526
- // `name:"aui.push.task.kb-upload" AND status_code:ERROR` finds
1527
- // every KB push failure across all agents.
1528
1320
  const kbUpTracer = getTracer();
1529
1321
  await kbUpTracer.startActiveSpan("aui.push.task.kb-upload", async (span) => {
1530
1322
  span.setAttribute("push.task.type", "kb-upload");
@@ -1561,8 +1353,6 @@ async function pushKnowledgeHubs(projectRoot, projectConfig) {
1561
1353
  fs.writeFileSync(kbJsonPath, JSON.stringify(raw, null, 2) + "\n");
1562
1354
  }
1563
1355
  catch (writeErr) {
1564
- // kb.json id write fail is non-fatal but tell the user so the
1565
- // next push doesn't surprise them with "no knowledge_base_id stored".
1566
1356
  span.addEvent("kb_id_writeback_failed");
1567
1357
  if (process.env.AUI_DEBUG) {
1568
1358
  console.warn(`[debug] failed to write knowledge_base_id back to ${kbJsonPath}:`, writeErr);
@@ -1573,9 +1363,13 @@ async function pushKnowledgeHubs(projectRoot, projectConfig) {
1573
1363
  }
1574
1364
  catch (uploadErr) {
1575
1365
  hadUploadFailure = true;
1576
- const errMsg = uploadErr instanceof Error ? uploadErr.message : String(uploadErr);
1366
+ const errMsg = uploadErr instanceof Error
1367
+ ? uploadErr.message
1368
+ : String(uploadErr);
1577
1369
  span.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
1578
- span.recordException(uploadErr instanceof Error ? uploadErr : new Error(errMsg));
1370
+ span.recordException(uploadErr instanceof Error
1371
+ ? uploadErr
1372
+ : new Error(errMsg));
1579
1373
  span.setAttribute("push.task.error", errMsg);
1580
1374
  if (uploadErr.statusCode) {
1581
1375
  span.setAttribute("push.task.error_status_code", uploadErr.statusCode);
@@ -1701,8 +1495,43 @@ async function pushKnowledgeHubs(projectRoot, projectConfig) {
1701
1495
  }
1702
1496
  return { ok: kbPushSucceeded && failures.length === 0, failures };
1703
1497
  }
1704
- // ─── Array File Info Helper ───
1498
+ // ─── Diff helper: read array metadata from a known top-level shape ───
1705
1499
  function getArrayFileInfoForPush(filePath, dir) {
1500
+ // Filename-first detection (same rationale as the classifier in
1501
+ // `_push`'s read loop) — under the new /pull endpoint the canonical
1502
+ // files may or may not be wrapped depending on push origin, but
1503
+ // their filenames are stable. `agent.aui.json` is intentionally
1504
+ // NOT in this map: it's a single general_settings object, not an
1505
+ // array file, so item-level diff doesn't apply.
1506
+ const basename = path.basename(filePath);
1507
+ const FILENAME_ARRAY_INFO = {
1508
+ "parameters.aui.json": {
1509
+ arrayKey: "parameters",
1510
+ itemKey: "code",
1511
+ label: "Parameters",
1512
+ },
1513
+ "entities.aui.json": {
1514
+ arrayKey: "entities",
1515
+ itemKey: "name",
1516
+ label: "Entities",
1517
+ },
1518
+ "integrations.aui.json": {
1519
+ arrayKey: "integrations",
1520
+ itemKey: "code",
1521
+ label: "Integrations",
1522
+ },
1523
+ "rules.aui.json": {
1524
+ arrayKey: "rules",
1525
+ itemKey: "code",
1526
+ label: "Rules",
1527
+ },
1528
+ };
1529
+ if (basename in FILENAME_ARRAY_INFO) {
1530
+ return FILENAME_ARRAY_INFO[basename];
1531
+ }
1532
+ // Fallback to content-based detection for non-canonical filenames
1533
+ // (legacy projects, custom layouts). Tool files are NOT array
1534
+ // files — return null so the per-tool diff stays at file-level.
1706
1535
  try {
1707
1536
  const fullPath = path.join(dir, filePath);
1708
1537
  if (!fs.existsSync(fullPath))
@@ -1729,7 +1558,7 @@ function getArrayFileInfoForPush(filePath, dir) {
1729
1558
  return null;
1730
1559
  }
1731
1560
  }
1732
- function writePushMemory(projectRoot, agentCode, agentId, pushTasks, succeededFiles, pushFailures) {
1561
+ function writePushMemory(projectRoot, input) {
1733
1562
  try {
1734
1563
  const memoryDir = path.join(projectRoot, "memory");
1735
1564
  fs.mkdirSync(memoryDir, { recursive: true });
@@ -1737,80 +1566,52 @@ function writePushMemory(projectRoot, agentCode, agentId, pushTasks, succeededFi
1737
1566
  const timestamp = now.toISOString().replace(/[:.]/g, "-");
1738
1567
  const dateStr = now.toISOString().replace("T", " ").substring(0, 19);
1739
1568
  const filename = `push-${timestamp}.md`;
1740
- const succeededSet = new Set(succeededFiles);
1741
- const failureMap = new Map();
1742
- for (const f of pushFailures) {
1743
- failureMap.set(f.label, f.error);
1744
- }
1745
- const totalSucceeded = pushTasks.filter((t) => (t.file && succeededSet.has(t.file)) ||
1746
- (!t.file && !failureMap.has(t.label))).length;
1747
- const totalFailed = pushFailures.length;
1748
- const overallStatus = totalFailed === 0 ? "SUCCESS" : totalSucceeded > 0 ? "PARTIAL" : "FAILED";
1569
+ const overallStatus = input.pushSucceeded
1570
+ ? input.kbFailures.length === 0
1571
+ ? "SUCCESS"
1572
+ : "PARTIAL"
1573
+ : "FAILED";
1749
1574
  const lines = [
1750
1575
  `# Push ${dateStr}`,
1751
1576
  "",
1752
- `**Agent:** ${agentCode}`,
1753
- `**Agent ID:** ${agentId}`,
1577
+ `**Agent:** ${input.agentCode}`,
1578
+ `**Agent ID:** ${input.agentId}`,
1579
+ `**Draft:** ${input.versionLabel}`,
1580
+ `**New tag:** ${input.newVersionTag ?? "(none — push did not succeed)"}`,
1581
+ `**Revision ID:** ${input.revisionId ?? "(none)"}`,
1754
1582
  `**Status:** ${overallStatus}`,
1755
- `**Total:** ${totalSucceeded} succeeded, ${totalFailed} failed`,
1756
- "",
1757
- "## Changes",
1583
+ `**Bundle:** ${input.uploadedFiles.length} file(s) uploaded`,
1758
1584
  "",
1759
1585
  ];
1760
- const tasksByCategory = {};
1761
- for (const task of pushTasks) {
1762
- const category = task.type.split("-").slice(1).join(" ");
1763
- if (!tasksByCategory[category])
1764
- tasksByCategory[category] = [];
1765
- tasksByCategory[category].push(task);
1586
+ if (input.commitMessage) {
1587
+ lines.push(`**Commit message:** ${input.commitMessage}`);
1588
+ lines.push("");
1766
1589
  }
1767
- for (const [category, tasks] of Object.entries(tasksByCategory)) {
1768
- lines.push(`### ${category.charAt(0).toUpperCase() + category.slice(1)}`);
1590
+ if (!input.pushSucceeded && input.bundleError) {
1591
+ lines.push("## Bundle Error", "");
1592
+ lines.push("```");
1593
+ lines.push(input.bundleError);
1594
+ lines.push("```", "");
1595
+ }
1596
+ lines.push("## Files in bundle", "");
1597
+ for (const fd of input.fileData) {
1598
+ lines.push(`- \`${fd.file}\` _(${fd.type})_`);
1599
+ }
1600
+ lines.push("");
1601
+ if (input.diff && input.diff.changedFiles.length > 0) {
1602
+ lines.push("## Changed since last push", "");
1603
+ for (const change of input.diff.changedFiles) {
1604
+ lines.push(`- **${change.status}** \`${change.file}\``);
1605
+ }
1769
1606
  lines.push("");
1770
- for (const task of tasks) {
1771
- const isFailed = failureMap.has(task.label);
1772
- const status = isFailed ? "FAILED" : "OK";
1773
- const icon = isFailed ? "x" : "check";
1774
- const action = task.type.split("-")[0];
1775
- lines.push(`- :${icon}: **${task.label}** — ${status}`);
1776
- if (task.file) {
1777
- lines.push(` - File: \`${task.file}\``);
1778
- }
1779
- if (isFailed) {
1780
- lines.push(` - Error: ${failureMap.get(task.label)}`);
1781
- }
1782
- if (action === "delete") {
1783
- lines.push(` - Action: Deleted`);
1784
- }
1785
- else if (action === "create" || !task.oldBody) {
1786
- lines.push(` - Action: Created`);
1787
- lines.push(` - Body:`);
1788
- lines.push(" ```json");
1789
- lines.push(` ${JSON.stringify(task.body, null, 2).split("\n").join("\n ")}`);
1790
- lines.push(" ```");
1791
- }
1792
- else {
1793
- const fieldDiffs = diffObjects(task.oldBody, task.body);
1794
- if (fieldDiffs.length > 0) {
1795
- lines.push(` - Action: Modified`);
1796
- lines.push(` - Diff:`);
1797
- for (const fd of fieldDiffs) {
1798
- if (fd.type === "added") {
1799
- lines.push(` - **+ ${fd.path}**: \`${truncate(fd.newValue)}\``);
1800
- }
1801
- else if (fd.type === "removed") {
1802
- lines.push(` - **- ${fd.path}**: ~~\`${truncate(fd.oldValue)}\`~~`);
1803
- }
1804
- else {
1805
- lines.push(` - **~ ${fd.path}**: \`${truncate(fd.oldValue)}\` → \`${truncate(fd.newValue)}\``);
1806
- }
1807
- }
1808
- }
1809
- else {
1810
- lines.push(` - Action: Modified (no field-level diff detected)`);
1811
- }
1812
- }
1813
- lines.push("");
1607
+ }
1608
+ if (input.kbFailures.length > 0) {
1609
+ lines.push("## Knowledge-base failures", "");
1610
+ for (const f of input.kbFailures) {
1611
+ lines.push(`- :x: **${f.label}**`);
1612
+ lines.push(` - Error: ${f.error}`);
1613
+ if (f.file)
1614
+ lines.push(` - File: \`${f.file}\``);
1814
1615
  }
1815
1616
  lines.push("");
1816
1617
  }
@@ -1820,673 +1621,10 @@ function writePushMemory(projectRoot, agentCode, agentId, pushTasks, succeededFi
1820
1621
  }
1821
1622
  catch (err) {
1822
1623
  // Memory file is diagnostic only — its failure shouldn't block the push.
1823
- // But emit a debug warning so an operator chasing "where's my push memory"
1824
- // sees what went wrong.
1825
1624
  if (process.env.AUI_DEBUG) {
1826
1625
  console.warn("[debug] writePushMemory failed:", err instanceof Error ? err.message : err);
1827
1626
  }
1828
1627
  return undefined;
1829
1628
  }
1830
1629
  }
1831
- function truncate(value, maxLen = 120) {
1832
- const str = typeof value === "string" ? value : JSON.stringify(value);
1833
- if (!str)
1834
- return "(empty)";
1835
- return str.length > maxLen ? str.substring(0, maxLen) + "..." : str;
1836
- }
1837
- function diffObjects(oldObj, newObj, prefix = "") {
1838
- const diffs = [];
1839
- const allKeys = new Set([
1840
- ...Object.keys(oldObj || {}),
1841
- ...Object.keys(newObj || {}),
1842
- ]);
1843
- for (const key of Array.from(allKeys)) {
1844
- const p = prefix ? `${prefix}.${key}` : key;
1845
- const oldVal = oldObj?.[key];
1846
- const newVal = newObj?.[key];
1847
- if (oldVal === undefined && newVal !== undefined) {
1848
- diffs.push({ path: p, type: "added", newValue: newVal });
1849
- }
1850
- else if (oldVal !== undefined && newVal === undefined) {
1851
- diffs.push({ path: p, type: "removed", oldValue: oldVal });
1852
- }
1853
- else if (oldVal !== null &&
1854
- newVal !== null &&
1855
- typeof oldVal === "object" &&
1856
- typeof newVal === "object" &&
1857
- !Array.isArray(oldVal) &&
1858
- !Array.isArray(newVal)) {
1859
- diffs.push(...diffObjects(oldVal, newVal, p));
1860
- }
1861
- else if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
1862
- diffs.push({
1863
- path: p,
1864
- type: "changed",
1865
- oldValue: oldVal,
1866
- newValue: newVal,
1867
- });
1868
- }
1869
- }
1870
- return diffs;
1871
- }
1872
- /**
1873
- * Build the ordered list of API tasks the push will execute, given a git
1874
- * diff summary, the parsed file contents, and an injectable per-file diff
1875
- * function.
1876
- *
1877
- * `getFileDiffFn` is parameterized so tests can stub out `git show` without
1878
- * a real git repo. `getItemLevelDiffFn` is optional and defaults to the
1879
- * production implementation; tests inject it so the array-keyed branches
1880
- * (parameters, entities, integrations) are exercisable without git too.
1881
- *
1882
- * Exported for unit-test access only — production callers should keep
1883
- * going through `_push`, which threads in the real `getFileDiff` and
1884
- * `getItemLevelDiff` from `../utils/git.js`.
1885
- */
1886
- export function buildPushTasks(diff, fileData, projectRoot, getFileDiffFn, getItemLevelDiffFn) {
1887
- const getItems = getItemLevelDiffFn ?? getItemLevelDiff;
1888
- const tasks = [];
1889
- for (const change of diff.changedFiles) {
1890
- if (change.status === "deleted") {
1891
- const isToolFile = change.file.includes("tools/") && change.file.endsWith(".aui.json");
1892
- if (isToolFile) {
1893
- const basename = change.file.split("/").pop().replace(".aui.json", "");
1894
- const toolName = basename.toUpperCase().replace(/-/g, "_");
1895
- tasks.push({
1896
- type: "delete-tool",
1897
- label: `Delete tool: ${toolName}`,
1898
- toolName,
1899
- file: change.file,
1900
- body: {},
1901
- });
1902
- }
1903
- continue;
1904
- }
1905
- const fd = fileData.find((f) => f.file === change.file ||
1906
- change.file.endsWith("/" + f.file) ||
1907
- f.file.endsWith("/" + change.file));
1908
- if (!fd)
1909
- continue;
1910
- if (fd.type === "tool") {
1911
- const toolData = fd.data?.tool || fd.data;
1912
- if (!toolData || !toolData.code)
1913
- continue;
1914
- const toolCode = toolData.code || "";
1915
- const toolName = toolCode.toUpperCase().replace(/-/g, "_");
1916
- if (change.status === "added") {
1917
- tasks.push({
1918
- type: "create-tool",
1919
- label: `Create tool: ${toolCode}`,
1920
- file: change.file,
1921
- body: toolData,
1922
- });
1923
- }
1924
- else if (change.status === "modified") {
1925
- // `change.status === "modified"` comes from `getDiffSummary` (git
1926
- // status). That alone is the source of truth that the file content
1927
- // diverged from the baseline; always push the patch. The full
1928
- // `toolData` body is sent (consistent with the create-tool branch
1929
- // immediately above); the platform PATCH is idempotent for fields
1930
- // whose value didn't actually change, so this is safe.
1931
- //
1932
- // Pre-fix this branch routed the changes through a
1933
- // `buildToolPatchBody` gate that only matched diff paths starting
1934
- // with the wrapped-shape `tool.` prefix. Tool files written in flat
1935
- // shape (no `{ "tool": {...} }` wrapper — accepted by the loader at
1936
- // ~L2675 and by `aui validate`) diffed to top-level paths
1937
- // (`response_type`, `widget`, `goal`, …), the gate returned an
1938
- // empty body, and the `patch-tool` task was silently dropped.
1939
- // Symptom users hit: `aui diff` showed the change, `aui push`
1940
- // printed "No pushable changes detected" and exited 0.
1941
- tasks.push({
1942
- type: "patch-tool",
1943
- label: `Update tool: ${toolCode}`,
1944
- toolName,
1945
- file: change.file,
1946
- body: toolData,
1947
- });
1948
- }
1949
- }
1950
- else if (fd.type === "general_settings") {
1951
- if (change.status === "modified") {
1952
- const fieldDiffs = getFileDiffFn(projectRoot, change.file);
1953
- const patchBody = buildSettingsPatchBody(fieldDiffs);
1954
- if (Object.keys(patchBody).length > 0) {
1955
- tasks.push({
1956
- type: "patch-general-settings",
1957
- label: "Update general settings",
1958
- file: change.file,
1959
- body: patchBody,
1960
- });
1961
- }
1962
- }
1963
- else if (change.status === "added") {
1964
- // Mirror the tool loader's `(fd.data as any)?.tool || fd.data`
1965
- // tolerance: accept both `{ "general_settings": {...} }` (wrapped)
1966
- // and `{...}` (flat). `normalizeGeneralSettings` in
1967
- // `pull-agent.tsx` / `import-agent.tsx` and `aui validate`
1968
- // (`commands/validate.tsx:222`) already permit both layouts —
1969
- // staying in lockstep with them here prevents the same
1970
- // "silently dropped" class of bug the tool-modify branch had.
1971
- const settings = fd.data?.general_settings ?? fd.data;
1972
- if (settings) {
1973
- tasks.push({
1974
- type: "patch-general-settings",
1975
- label: "Update general settings",
1976
- file: change.file,
1977
- body: settings,
1978
- });
1979
- }
1980
- }
1981
- }
1982
- else if (fd.type === "parameters") {
1983
- const itemDiffs = getItems(projectRoot, change.file, "parameters", "code");
1984
- for (const item of itemDiffs) {
1985
- if (item.status === "added") {
1986
- tasks.push({
1987
- type: "create-parameter",
1988
- label: `Create parameter: ${item.key}`,
1989
- file: change.file,
1990
- body: item.newItem,
1991
- itemCode: item.key,
1992
- });
1993
- }
1994
- else if (item.status === "modified") {
1995
- tasks.push({
1996
- type: "patch-parameter",
1997
- label: `Update parameter: ${item.key}`,
1998
- file: change.file,
1999
- body: item.newItem,
2000
- itemCode: item.key,
2001
- });
2002
- }
2003
- else if (item.status === "deleted") {
2004
- tasks.push({
2005
- type: "delete-parameter",
2006
- label: `Delete parameter: ${item.key}`,
2007
- file: change.file,
2008
- body: item.oldItem,
2009
- itemCode: item.key,
2010
- });
2011
- }
2012
- }
2013
- }
2014
- else if (fd.type === "entities") {
2015
- const itemDiffs = getItems(projectRoot, change.file, "entities", "name");
2016
- for (const item of itemDiffs) {
2017
- if (item.status === "added") {
2018
- tasks.push({
2019
- type: "create-entity",
2020
- label: `Create entity: ${item.key}`,
2021
- file: change.file,
2022
- body: item.newItem,
2023
- itemCode: item.key,
2024
- });
2025
- }
2026
- else if (item.status === "modified") {
2027
- tasks.push({
2028
- type: "patch-entity",
2029
- label: `Update entity: ${item.key}`,
2030
- file: change.file,
2031
- body: item.newItem,
2032
- itemCode: item.key,
2033
- });
2034
- }
2035
- else if (item.status === "deleted") {
2036
- tasks.push({
2037
- type: "delete-entity",
2038
- label: `Delete entity: ${item.key}`,
2039
- file: change.file,
2040
- body: item.oldItem,
2041
- itemCode: item.key,
2042
- });
2043
- }
2044
- }
2045
- }
2046
- else if (fd.type === "integrations") {
2047
- const itemDiffs = getItems(projectRoot, change.file, "integrations", "code");
2048
- for (const item of itemDiffs) {
2049
- if (item.status === "added") {
2050
- tasks.push({
2051
- type: "create-integration",
2052
- label: `Create integration: ${item.key}`,
2053
- file: change.file,
2054
- body: item.newItem,
2055
- itemCode: item.key,
2056
- });
2057
- }
2058
- else if (item.status === "modified") {
2059
- tasks.push({
2060
- type: "patch-integration",
2061
- label: `Update integration: ${item.key}`,
2062
- file: change.file,
2063
- body: item.newItem,
2064
- itemCode: item.key,
2065
- });
2066
- }
2067
- else if (item.status === "deleted") {
2068
- tasks.push({
2069
- type: "delete-integration",
2070
- label: `Delete integration: ${item.key}`,
2071
- file: change.file,
2072
- body: item.oldItem,
2073
- itemCode: item.key,
2074
- });
2075
- }
2076
- }
2077
- }
2078
- else if (fd.type === "rules") {
2079
- const rulesData = fd.data;
2080
- tasks.push({
2081
- type: "put-rules",
2082
- label: "Update rules",
2083
- file: change.file,
2084
- body: rulesData,
2085
- });
2086
- }
2087
- }
2088
- return tasks;
2089
- }
2090
- // NOTE: `buildToolPatchBody` was removed when the tool-modify branch above
2091
- // switched to pushing on any `change.status === "modified"`. Its sole job
2092
- // had been to gate the patch-tool task on a `tool.<field>` diff-path prefix,
2093
- // which silently dropped every edit on flat-shape (no-wrapper) tool files.
2094
- // See the inline comment in the modify branch (~L2688) for the full history.
2095
- export function buildSettingsPatchBody(fieldDiffs) {
2096
- const body = {};
2097
- for (const fd of fieldDiffs) {
2098
- if (fd.operation === "removed")
2099
- continue;
2100
- const parts = fd.path.split(".");
2101
- if (parts.length === 0 || !parts[0])
2102
- continue;
2103
- // general_settings (agent.aui.json) files come in two equally-supported
2104
- // shapes:
2105
- // wrapped: { "general_settings": { ...fields... } } → paths look like
2106
- // `general_settings.<field>...`
2107
- // flat: { ...fields... } → paths look like
2108
- // `<field>...`
2109
- // `aui validate` (`commands/validate.tsx:222`) and
2110
- // `normalizeGeneralSettings` in `import-agent.tsx` / `pull-agent.tsx`
2111
- // already accept both layouts. Pre-fix this body builder only matched
2112
- // the wrapped form, which silently dropped edits to flat-shape files.
2113
- // Match the loader behavior here so every change reaches the platform.
2114
- const topLevelKey = parts[0] === "general_settings" && parts.length >= 2
2115
- ? parts[1]
2116
- : parts[0];
2117
- body[topLevelKey] = fd.newValue;
2118
- }
2119
- return body;
2120
- }
2121
- // ─── Push Task Executor ───
2122
- async function executePushTask(client, params, task) {
2123
- const tracer = getTracer();
2124
- return tracer.startActiveSpan(`aui.push.task.${task.type}`, async (span) => {
2125
- span.setAttribute("push.task.type", task.type);
2126
- span.setAttribute("push.task.label", task.label);
2127
- if (task.file)
2128
- span.setAttribute("push.task.file", task.file);
2129
- if (task.toolName)
2130
- span.setAttribute("push.task.tool_name", task.toolName);
2131
- if (task.itemCode)
2132
- span.setAttribute("push.task.item_code", task.itemCode);
2133
- // Tag every task span with who ran it and which agent/version/scope
2134
- // it targeted. The parent `aui.push` span already has these, but the
2135
- // Logfire row view doesn't carry parent attrs onto children — so a
2136
- // 500 on `aui.push.task.patch-tool` needs them attached locally for
2137
- // a triage glance to be self-sufficient. Identifiers only — no
2138
- // tokens. See `setAgentContext` JSDoc for the safety rationale.
2139
- await setUserContext(span);
2140
- await setAgentContext(span, {
2141
- agentId: params.agent_id,
2142
- versionId: params.version_id,
2143
- networkId: params.network_id,
2144
- accountId: params.account_id,
2145
- organizationId: params.organization_id,
2146
- networkCategoryId: params.network_category_id,
2147
- });
2148
- try {
2149
- span.setAttribute("push.task.request_payload", JSON.stringify(task.body));
2150
- const result = await _executePushTask(client, params, task);
2151
- span.setAttribute("push.task.response", JSON.stringify(result));
2152
- span.setStatus({ code: SpanStatusCode.OK });
2153
- return result;
2154
- }
2155
- catch (error) {
2156
- const errMsg = error instanceof Error ? error.message : String(error);
2157
- span.setStatus({ code: SpanStatusCode.ERROR, message: errMsg });
2158
- span.recordException(error instanceof Error ? error : new Error(errMsg));
2159
- span.setAttribute("push.task.error", errMsg);
2160
- if (error.statusCode)
2161
- span.setAttribute("push.task.error_status_code", error.statusCode);
2162
- if (error.responseBody)
2163
- span.setAttribute("push.task.error_response", JSON.stringify(error.responseBody));
2164
- throw error;
2165
- }
2166
- finally {
2167
- span.end();
2168
- }
2169
- });
2170
- }
2171
- // ─── Adaptive fallback matrix (per write task) ────────────────────────────
2172
- //
2173
- // Every entity write goes through three layers, applied in this order:
2174
- //
2175
- // (a) `withTransientRetry` — retries once on 500/502/503/504 with a 1s
2176
- // back-off. Per-call, isolated from other
2177
- // tasks. 4xx is never retried (deterministic).
2178
- // (b) `POST 409 → PATCH` — the create call hit a row with the same
2179
- // code; the platform already has it. Convert
2180
- // to a PATCH and continue. Pre-existing.
2181
- // (c) `PATCH 404 → POST` — the patch call hit "not found"; baseline
2182
- // drifted (item never landed on the platform
2183
- // from a prior partial push). Convert to a
2184
- // POST so the row reappears. NEW.
2185
- // (d) `DELETE 404 → success` — the delete target is already gone. The
2186
- // desired end state is reached. Treat as
2187
- // success and log "(already absent)" so the
2188
- // user can see what happened. NEW.
2189
- //
2190
- // All four layers are visible in the per-task push log files under
2191
- // `.aui/push-logs/` so the BFF / agent-builder-bff can audit decisions.
2192
- function isAlreadyExistsConflict(err) {
2193
- if (!err || typeof err !== "object")
2194
- return false;
2195
- const code = err.statusCode
2196
- ?? err.status;
2197
- return code === 409;
2198
- }
2199
- function isNotFoundError(err) {
2200
- if (!err || typeof err !== "object")
2201
- return false;
2202
- const code = err.statusCode
2203
- ?? err.status;
2204
- return code === 404;
2205
- }
2206
- function isTransient5xx(err) {
2207
- if (!err || typeof err !== "object")
2208
- return false;
2209
- const code = err.statusCode
2210
- ?? err.status;
2211
- return code === 500 || code === 502 || code === 503 || code === 504;
2212
- }
2213
- /**
2214
- * Tag the currently-active span with a fallback-decision event + attribute,
2215
- * so Logfire shows exactly which adaptive layer fired during a push.
2216
- *
2217
- * Useful queries once published:
2218
- * - `attributes."push.task.fallback":"patch_404_to_post"` → every drift
2219
- * recovery (next push self-healed a previously-failed POST).
2220
- * - `attributes."push.task.fallback":"transient_retry"` → backend 5xx
2221
- * events that were absorbed by the retry layer.
2222
- * - `attributes."push.task.fallback":"post_409_to_patch"` → "create"
2223
- * calls that converted to "update" because the row pre-existed.
2224
- * - `attributes."push.task.fallback":"delete_404_already_absent"` →
2225
- * deletes that no-op'd because the row was already gone.
2226
- *
2227
- * No-op when there's no active span (e.g. unit tests outside the push flow).
2228
- */
2229
- function recordFallbackEvent(kind, detail) {
2230
- const span = trace.getActiveSpan();
2231
- if (!span)
2232
- return;
2233
- span.addEvent(`fallback.${kind}`, detail);
2234
- span.setAttribute("push.task.fallback", kind);
2235
- for (const [k, v] of Object.entries(detail ?? {})) {
2236
- span.setAttribute(`push.task.fallback.${k}`, v);
2237
- }
2238
- }
2239
- /**
2240
- * Run one entity-settings write call once, and retry exactly once on a
2241
- * transient 5xx after a 1s back-off. The snapshot upload has its own
2242
- * retry loop (see `pushSnapshot`); this is the equivalent for individual
2243
- * agent-settings writes. Never retries on 4xx — those are deterministic.
2244
- */
2245
- async function withTransientRetry(label, fn) {
2246
- try {
2247
- return await fn();
2248
- }
2249
- catch (err) {
2250
- if (!isTransient5xx(err))
2251
- throw err;
2252
- const code = err.statusCode
2253
- ?? err.status;
2254
- if (process.env.AUI_DEBUG) {
2255
- console.log(`[debug] ${label} got ${code}, retrying once after 1000ms`);
2256
- }
2257
- recordFallbackEvent("transient_retry", {
2258
- label,
2259
- status_code: code ?? 0,
2260
- backoff_ms: 1000,
2261
- });
2262
- await new Promise((r) => setTimeout(r, 1000));
2263
- return await fn();
2264
- }
2265
- }
2266
- /**
2267
- * A delete that has been short-circuited because the row was already absent
2268
- * on the platform. Returned as a successful resolution so callers don't
2269
- * count the task as failed, but tagged so the per-task log line can show
2270
- * "(already absent)" instead of a generic ✓.
2271
- */
2272
- const DELETE_ALREADY_ABSENT = Object.freeze({
2273
- __aui_already_absent__: true,
2274
- message: "Already absent on platform — treated as success",
2275
- });
2276
- function isAlreadyAbsentResult(value) {
2277
- return (!!value
2278
- && typeof value === "object"
2279
- && value.__aui_already_absent__ === true);
2280
- }
2281
- async function _executePushTask(client, params, task) {
2282
- switch (task.type) {
2283
- case "patch-tool":
2284
- return withTransientRetry(`PATCH tool ${task.toolName}`, async () => {
2285
- try {
2286
- return await client.patchTool(params, task.toolName, task.body);
2287
- }
2288
- catch (err) {
2289
- if (isNotFoundError(err)) {
2290
- if (process.env.AUI_DEBUG) {
2291
- console.log(`[debug] patch-tool ${task.toolName}: 404 not found, falling back to POST`);
2292
- }
2293
- recordFallbackEvent("patch_404_to_post", { task_type: "patch-tool", tool: String(task.toolName ?? "") });
2294
- return client.createTool(params, task.body);
2295
- }
2296
- throw err;
2297
- }
2298
- });
2299
- case "create-tool":
2300
- return withTransientRetry(`POST tool ${task.toolName ?? task.itemCode}`, async () => {
2301
- try {
2302
- return await client.createTool(params, task.body);
2303
- }
2304
- catch (err) {
2305
- if (isAlreadyExistsConflict(err)) {
2306
- if (process.env.AUI_DEBUG) {
2307
- console.log(`[debug] create-tool: 409 already-exists, falling back to PATCH`);
2308
- }
2309
- const body = task.body;
2310
- const toolCode = body.code || "";
2311
- const toolName = toolCode.toUpperCase().replace(/-/g, "_");
2312
- recordFallbackEvent("post_409_to_patch", { task_type: "create-tool", tool: toolName });
2313
- return client.patchTool(params, toolName, body);
2314
- }
2315
- throw err;
2316
- }
2317
- });
2318
- case "delete-tool":
2319
- return withTransientRetry(`DELETE tool ${task.toolName}`, async () => {
2320
- try {
2321
- return await client.deleteTool(params, task.toolName);
2322
- }
2323
- catch (err) {
2324
- if (isNotFoundError(err)) {
2325
- if (process.env.AUI_DEBUG) {
2326
- console.log(`[debug] delete-tool ${task.toolName}: 404 already absent`);
2327
- }
2328
- recordFallbackEvent("delete_404_already_absent", { task_type: "delete-tool", tool: String(task.toolName ?? "") });
2329
- return DELETE_ALREADY_ABSENT;
2330
- }
2331
- throw err;
2332
- }
2333
- });
2334
- case "patch-general-settings":
2335
- return withTransientRetry("PATCH general-settings", () => client.patchGeneralSettings(params, task.body));
2336
- case "put-parameters":
2337
- return withTransientRetry("PUT parameters", () => client.putParameters(params, task.body, task.oldBody));
2338
- case "put-entities":
2339
- return withTransientRetry("PUT entities", () => client.putEntities(params, task.body, task.oldBody));
2340
- case "put-integrations":
2341
- return withTransientRetry("PUT integrations", () => client.putIntegrations(params, task.body, task.oldBody));
2342
- case "create-parameter":
2343
- return withTransientRetry(`POST param ${task.itemCode}`, async () => {
2344
- try {
2345
- return await client.createParameter(params, task.body);
2346
- }
2347
- catch (err) {
2348
- if (isAlreadyExistsConflict(err)) {
2349
- if (process.env.AUI_DEBUG) {
2350
- console.log(`[debug] create-parameter ${task.itemCode}: 409, falling back to PATCH`);
2351
- }
2352
- recordFallbackEvent("post_409_to_patch", { task_type: "create-parameter", code: String(task.itemCode ?? "") });
2353
- return client.patchParameter(params, task.itemCode, task.body);
2354
- }
2355
- throw err;
2356
- }
2357
- });
2358
- case "patch-parameter":
2359
- return withTransientRetry(`PATCH param ${task.itemCode}`, async () => {
2360
- try {
2361
- return await client.patchParameter(params, task.itemCode, task.body);
2362
- }
2363
- catch (err) {
2364
- if (isNotFoundError(err)) {
2365
- if (process.env.AUI_DEBUG) {
2366
- console.log(`[debug] patch-parameter ${task.itemCode}: 404 not found, falling back to POST`);
2367
- }
2368
- recordFallbackEvent("patch_404_to_post", { task_type: "patch-parameter", code: String(task.itemCode ?? "") });
2369
- return client.createParameter(params, task.body);
2370
- }
2371
- throw err;
2372
- }
2373
- });
2374
- case "delete-parameter":
2375
- return withTransientRetry(`DELETE param ${task.itemCode}`, async () => {
2376
- try {
2377
- return await client.deleteParameter(params, task.itemCode, task.body);
2378
- }
2379
- catch (err) {
2380
- if (isNotFoundError(err)) {
2381
- if (process.env.AUI_DEBUG) {
2382
- console.log(`[debug] delete-parameter ${task.itemCode}: 404 already absent`);
2383
- }
2384
- recordFallbackEvent("delete_404_already_absent", { task_type: "delete-parameter", code: String(task.itemCode ?? "") });
2385
- return DELETE_ALREADY_ABSENT;
2386
- }
2387
- throw err;
2388
- }
2389
- });
2390
- case "create-entity":
2391
- return withTransientRetry(`POST entity ${task.itemCode}`, async () => {
2392
- try {
2393
- return await client.createEntity(params, task.body);
2394
- }
2395
- catch (err) {
2396
- if (isAlreadyExistsConflict(err)) {
2397
- if (process.env.AUI_DEBUG) {
2398
- console.log(`[debug] create-entity ${task.itemCode}: 409, falling back to PATCH`);
2399
- }
2400
- recordFallbackEvent("post_409_to_patch", { task_type: "create-entity", code: String(task.itemCode ?? "") });
2401
- return client.patchEntity(params, task.itemCode, task.body);
2402
- }
2403
- throw err;
2404
- }
2405
- });
2406
- case "patch-entity":
2407
- return withTransientRetry(`PATCH entity ${task.itemCode}`, async () => {
2408
- try {
2409
- return await client.patchEntity(params, task.itemCode, task.body);
2410
- }
2411
- catch (err) {
2412
- if (isNotFoundError(err)) {
2413
- if (process.env.AUI_DEBUG) {
2414
- console.log(`[debug] patch-entity ${task.itemCode}: 404, falling back to POST`);
2415
- }
2416
- recordFallbackEvent("patch_404_to_post", { task_type: "patch-entity", code: String(task.itemCode ?? "") });
2417
- return client.createEntity(params, task.body);
2418
- }
2419
- throw err;
2420
- }
2421
- });
2422
- case "delete-entity":
2423
- return withTransientRetry(`DELETE entity ${task.itemCode}`, async () => {
2424
- try {
2425
- return await client.deleteEntity(params, task.itemCode);
2426
- }
2427
- catch (err) {
2428
- if (isNotFoundError(err)) {
2429
- if (process.env.AUI_DEBUG) {
2430
- console.log(`[debug] delete-entity ${task.itemCode}: 404 already absent`);
2431
- }
2432
- recordFallbackEvent("delete_404_already_absent", { task_type: "delete-entity", code: String(task.itemCode ?? "") });
2433
- return DELETE_ALREADY_ABSENT;
2434
- }
2435
- throw err;
2436
- }
2437
- });
2438
- case "create-integration":
2439
- return withTransientRetry(`POST integration ${task.itemCode}`, async () => {
2440
- try {
2441
- return await client.createIntegration(params, task.body);
2442
- }
2443
- catch (err) {
2444
- if (isAlreadyExistsConflict(err)) {
2445
- if (process.env.AUI_DEBUG) {
2446
- console.log(`[debug] create-integration ${task.itemCode}: 409, falling back to PATCH`);
2447
- }
2448
- recordFallbackEvent("post_409_to_patch", { task_type: "create-integration", code: String(task.itemCode ?? "") });
2449
- return client.patchIntegration(params, task.itemCode, task.body);
2450
- }
2451
- throw err;
2452
- }
2453
- });
2454
- case "patch-integration":
2455
- return withTransientRetry(`PATCH integration ${task.itemCode}`, async () => {
2456
- try {
2457
- return await client.patchIntegration(params, task.itemCode, task.body);
2458
- }
2459
- catch (err) {
2460
- if (isNotFoundError(err)) {
2461
- if (process.env.AUI_DEBUG) {
2462
- console.log(`[debug] patch-integration ${task.itemCode}: 404 not found, falling back to POST`);
2463
- }
2464
- recordFallbackEvent("patch_404_to_post", { task_type: "patch-integration", code: String(task.itemCode ?? "") });
2465
- return client.createIntegration(params, task.body);
2466
- }
2467
- throw err;
2468
- }
2469
- });
2470
- case "delete-integration":
2471
- return withTransientRetry(`DELETE integration ${task.itemCode}`, async () => {
2472
- try {
2473
- return await client.deleteIntegration(params, task.itemCode);
2474
- }
2475
- catch (err) {
2476
- if (isNotFoundError(err)) {
2477
- if (process.env.AUI_DEBUG) {
2478
- console.log(`[debug] delete-integration ${task.itemCode}: 404 already absent`);
2479
- }
2480
- recordFallbackEvent("delete_404_already_absent", { task_type: "delete-integration", code: String(task.itemCode ?? "") });
2481
- return DELETE_ALREADY_ABSENT;
2482
- }
2483
- throw err;
2484
- }
2485
- });
2486
- case "put-rules":
2487
- return withTransientRetry("PUT rules", () => client.putRules(params, task.body));
2488
- default:
2489
- throw new Error(`Unknown push task type: ${task.type}`);
2490
- }
2491
- }
2492
1630
  //# sourceMappingURL=push.js.map