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