@aui.io/apollo 0.1.49 → 0.1.52
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/node_modules/@aui.io/apollo-cli/dist/src/commands/bundles.js +8 -0
- package/node_modules/@aui.io/apollo-cli/dist/src/commands/bundles.js.map +1 -1
- package/node_modules/@aui.io/apollo-cli/dist/src/commands/mockdb.js +132 -21
- package/node_modules/@aui.io/apollo-cli/dist/src/commands/mockdb.js.map +1 -1
- package/node_modules/@aui.io/apollo-cli/package.json +3 -3
- package/node_modules/@aui.io/apollo-core/dist/src/api/kbm.d.ts +14 -9
- package/node_modules/@aui.io/apollo-core/dist/src/api/kbm.js +10 -8
- package/node_modules/@aui.io/apollo-core/dist/src/api/kbm.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/api/mock-db.d.ts +104 -3
- package/node_modules/@aui.io/apollo-core/dist/src/api/mock-db.js +137 -6
- package/node_modules/@aui.io/apollo-core/dist/src/api/mock-db.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.d.ts +10 -1
- package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js +9 -2
- package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/api/transport.d.ts +3 -0
- package/node_modules/@aui.io/apollo-core/dist/src/api/transport.js +6 -2
- package/node_modules/@aui.io/apollo-core/dist/src/api/transport.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/index.d.ts +2 -2
- package/node_modules/@aui.io/apollo-core/dist/src/index.js +2 -2
- package/node_modules/@aui.io/apollo-core/dist/src/index.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/bundles.d.ts +16 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/bundles.js +38 -4
- package/node_modules/@aui.io/apollo-core/dist/src/services/bundles.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/knowledge.js +7 -2
- package/node_modules/@aui.io/apollo-core/dist/src/services/knowledge.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js +3 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/dist/src/services/mockdb.d.ts +86 -4
- package/node_modules/@aui.io/apollo-core/dist/src/services/mockdb.js +752 -47
- package/node_modules/@aui.io/apollo-core/dist/src/services/mockdb.js.map +1 -1
- package/node_modules/@aui.io/apollo-core/package.json +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js +8 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/author/resources.d.ts +5 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/author/resources.js +29 -3
- package/node_modules/@aui.io/apollo-tui/dist/src/author/resources.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js +58 -9
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js +28 -0
- package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js.map +1 -1
- package/node_modules/@aui.io/apollo-tui/package.json +2 -2
- package/package.json +4 -4
|
@@ -1,9 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock DB service layer.
|
|
3
|
+
*
|
|
4
|
+
* Drives the `mock-db-service` management + runtime planes on behalf of the
|
|
5
|
+
* `apollo mockdb` commands and the TUI. Pure logic — no terminal, no
|
|
6
|
+
* process.exit.
|
|
7
|
+
*
|
|
8
|
+
* ─── Identity & auth ────────────────────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* The agent comes from the checkout (`.apollorc`) or `--agent`; the Apollo
|
|
11
|
+
* token from the active profile. The token is sent to the mock-db service
|
|
12
|
+
* FIRST-PARTY and only where the service demands it (provision / status /
|
|
13
|
+
* delete / minting a management key). Every other management call prefers
|
|
14
|
+
* the per-agent `managementKey` (`X-Mgmt-Key`) — no Apollo token transmitted
|
|
15
|
+
* — and runtime calls use the per-agent `runtimeKey`. Keys are persisted
|
|
16
|
+
* under `~/.apollo/mockdb/<agentId>.json` (outside the checkout).
|
|
17
|
+
*
|
|
18
|
+
* ─── Resilience ─────────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* The shared backend occasionally answers a 2xx with a BLANK body under
|
|
21
|
+
* load. Reads retry that with bounded backoff and then fail loudly (never a
|
|
22
|
+
* silent `{}` that reads as an empty DB); writes surface it as a verify-first
|
|
23
|
+
* error (a repeat could duplicate rows); key rotation retries until a key
|
|
24
|
+
* actually comes back. Runtime calls also ride out read-after-write replica
|
|
25
|
+
* lag (422 no_results / transient 5xx) where a retry can never duplicate a
|
|
26
|
+
* write.
|
|
27
|
+
*/
|
|
1
28
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
29
|
import { homedir } from "node:os";
|
|
3
30
|
import path from "node:path";
|
|
4
|
-
import { MockDbClient, } from "../api/mock-db.js";
|
|
31
|
+
import { isMockDbTransportError, MockDbClient, mockDbErrorCode, } from "../api/mock-db.js";
|
|
5
32
|
import { clearMockDbKeys, keyPrefixes, loadMockDbKeys, saveMockDbKeys, } from "../config/mockdb-keys.js";
|
|
6
|
-
import { AuthError, ConfigError, ValidationError } from "../errors/index.js";
|
|
33
|
+
import { ApiError, AuthError, CliError, ConfigError, ValidationError, } from "../errors/index.js";
|
|
34
|
+
/** Env var the wired `connections.yaml` reads the runtime key from by default. */
|
|
35
|
+
export const DEFAULT_MOCKDB_TOKEN_ENV = "MOCKDB_TOKEN";
|
|
7
36
|
function accountIdFromToken(token) {
|
|
8
37
|
try {
|
|
9
38
|
const payload = token.split(".")[1];
|
|
@@ -64,111 +93,584 @@ export function resolveMockDb(context, options = {}) {
|
|
|
64
93
|
client,
|
|
65
94
|
agentId,
|
|
66
95
|
...(context.agentRoot ? { agentRoot: context.agentRoot } : {}),
|
|
96
|
+
accountId,
|
|
67
97
|
tokenAuth,
|
|
68
98
|
homeDir,
|
|
99
|
+
tokenEnv: options.tokenEnv?.trim() || DEFAULT_MOCKDB_TOKEN_ENV,
|
|
69
100
|
};
|
|
70
101
|
}
|
|
71
|
-
|
|
102
|
+
// ─── Blank-body guards (no silent empty-success) ────────────────────────
|
|
103
|
+
/** A read result is "empty" only when the HTTP body was blank → `request()`
|
|
104
|
+
* returned a zero-key `{}`. A real read (even of an empty DB) returns a keyed
|
|
105
|
+
* object like `{ collections: [] }`, so this never flags a legitimately-empty
|
|
106
|
+
* DB — only a genuinely blank body. */
|
|
107
|
+
function isEmptyReadObject(resp) {
|
|
108
|
+
return (resp != null &&
|
|
109
|
+
typeof resp === "object" &&
|
|
110
|
+
!Array.isArray(resp) &&
|
|
111
|
+
Object.keys(resp).length === 0);
|
|
112
|
+
}
|
|
113
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
114
|
+
/** True inside the unit-test harness (skip real backoff sleeps). */
|
|
115
|
+
function inTestEnv() {
|
|
116
|
+
return !!process.env.VITEST || process.env.NODE_ENV === "test";
|
|
117
|
+
}
|
|
118
|
+
/** Bounded exponential backoff (+ jitter) between blank-body retries. */
|
|
119
|
+
const BLANK_RETRY_ATTEMPTS = 5;
|
|
120
|
+
function backoffMs(attempt) {
|
|
121
|
+
const base = Math.min(250 * 2 ** (attempt - 1), 2000);
|
|
122
|
+
return base + Math.floor(Math.random() * 100);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Re-run `fn` while `retry(result)` is true, up to `BLANK_RETRY_ATTEMPTS`,
|
|
126
|
+
* with bounded backoff. Only ever wrap calls that are SAFE to repeat
|
|
127
|
+
* (idempotent reads, or key rotation which mints fresh each time); never
|
|
128
|
+
* naive writes (insert/seed) where a repeat could duplicate.
|
|
129
|
+
*/
|
|
130
|
+
async function retryWhile(retry, fn) {
|
|
131
|
+
const inTest = inTestEnv();
|
|
132
|
+
let result = await fn();
|
|
133
|
+
for (let i = 1; i < BLANK_RETRY_ATTEMPTS && retry(result); i++) {
|
|
134
|
+
if (!inTest)
|
|
135
|
+
await sleep(backoffMs(i));
|
|
136
|
+
result = await fn();
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Throw a precise, non-destructive error for a persistently-blank 2xx
|
|
142
|
+
* response. Worded per operation kind so the caller takes the SAFE next step
|
|
143
|
+
* (never "just retry" a write that may have landed; never "delete the DB" on
|
|
144
|
+
* a transient blip).
|
|
145
|
+
*/
|
|
146
|
+
function blankBodyError(resolved, op, kind) {
|
|
147
|
+
const head = `mockdb ${op}: the mock-db service (${resolved.context.baseUrls.mockDb}) returned HTTP 2xx but a blank body for agent ${resolved.agentId}` +
|
|
148
|
+
(kind === "write"
|
|
149
|
+
? ". The write may have already landed — NOT auto-retried, to avoid duplicate rows."
|
|
150
|
+
: ", even after retries.");
|
|
151
|
+
const common = "This is a TRANSIENT transport blank on the shared backend under load (an empty 2xx body), not data loss. ";
|
|
152
|
+
let tail;
|
|
153
|
+
if (kind === "read") {
|
|
154
|
+
tail =
|
|
155
|
+
"Your data is most likely intact — wait a moment and retry. Do NOT delete or re-provision the mock DB based on this alone.";
|
|
156
|
+
}
|
|
157
|
+
else if (kind === "mint") {
|
|
158
|
+
tail =
|
|
159
|
+
"No key was stored, so nothing changed and it is safe to re-run. If it persists, the backend key-mint path is failing — check `apollo mockdb status` and report it.";
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
tail =
|
|
163
|
+
"The write MAY have already landed server-side, so do NOT blindly retry (re-running an insert/seed can DUPLICATE rows). Verify first — `apollo mockdb describe` / `collections list` for base writes, `apollo mockdb session get --key <session>` for runtime writes — then retry only what's actually missing.";
|
|
164
|
+
}
|
|
165
|
+
throw new CliError(head, { code: "API_SERVER_ERROR", suggestion: common + tail });
|
|
166
|
+
}
|
|
167
|
+
/** Run an idempotent read, retrying a blank body, then failing loudly if it stays blank. */
|
|
168
|
+
async function readWithRetry(resolved, op, fn) {
|
|
169
|
+
const result = await retryWhile(isEmptyReadObject, fn);
|
|
170
|
+
if (isEmptyReadObject(result))
|
|
171
|
+
blankBodyError(resolved, op, "read");
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
function isAuthRejection(error) {
|
|
175
|
+
return error instanceof ApiError && (error.status === 401 || error.status === 403);
|
|
176
|
+
}
|
|
177
|
+
// ─── Key persistence ────────────────────────────────────────────────────
|
|
178
|
+
async function persistReturnedKeys(resolved, resp) {
|
|
72
179
|
const patch = {
|
|
73
|
-
mockDbUrl:
|
|
180
|
+
mockDbUrl: resolved.context.baseUrls.mockDb,
|
|
74
181
|
backend: "aui-managed",
|
|
75
182
|
};
|
|
76
|
-
if (typeof resp.managementKey === "string")
|
|
183
|
+
if (typeof resp.managementKey === "string" && resp.managementKey) {
|
|
77
184
|
patch.managementKey = resp.managementKey;
|
|
78
|
-
|
|
185
|
+
}
|
|
186
|
+
if (typeof resp.runtimeKey === "string" && resp.runtimeKey) {
|
|
79
187
|
patch.runtimeKey = resp.runtimeKey;
|
|
188
|
+
}
|
|
80
189
|
if (patch.managementKey || patch.runtimeKey) {
|
|
81
|
-
await saveMockDbKeys(agentId, patch, homeDir);
|
|
190
|
+
await saveMockDbKeys(resolved.agentId, patch, resolved.homeDir);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function responseHasKey(resp, field) {
|
|
194
|
+
return typeof resp[field] === "string" && resp[field].length > 0;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Mint-and-retry for key rotation. Under load the service can drop the body
|
|
198
|
+
* AFTER minting; returning `{}` then persists nothing — a false success that
|
|
199
|
+
* strands the caller with no usable key. Rotation is safe to repeat (each
|
|
200
|
+
* call mints fresh; the last one returned wins), so retry until the expected
|
|
201
|
+
* key comes back and persist it, else fail LOUDLY.
|
|
202
|
+
*/
|
|
203
|
+
async function rotateWithRetry(resolved, field, op, fn) {
|
|
204
|
+
const resp = await retryWhile((r) => !responseHasKey(r, field), fn);
|
|
205
|
+
if (!responseHasKey(resp, field))
|
|
206
|
+
blankBodyError(resolved, op, "mint");
|
|
207
|
+
await persistReturnedKeys(resolved, resp);
|
|
208
|
+
return resp;
|
|
209
|
+
}
|
|
210
|
+
// ─── Management auth: mgmt key first, token as the safety net ───────────
|
|
211
|
+
/**
|
|
212
|
+
* Mint + cache a fresh management key via the token-auth `rotate-mgmt-key`
|
|
213
|
+
* path. Returns the minted key, or `undefined` when minting isn't possible
|
|
214
|
+
* (agent not provisioned, no account, backend mint path failing) so callers
|
|
215
|
+
* fall back to token auth. Never throws.
|
|
216
|
+
*/
|
|
217
|
+
async function tryMintManagementKey(resolved) {
|
|
218
|
+
try {
|
|
219
|
+
const resp = await rotateWithRetry(resolved, "managementKey", "rotate mgmt", () => resolved.client.rotateMgmtKey(resolved.agentId, resolved.tokenAuth));
|
|
220
|
+
return typeof resp.managementKey === "string" ? resp.managementKey : undefined;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Run a management call. The management key is sourced from the cache first,
|
|
228
|
+
* and a fresh one is MINTED (+cached) when the cache has none or the stored
|
|
229
|
+
* one is rejected (401/403). The Apollo token is the safety net under every
|
|
230
|
+
* branch, so a missing/dead management key never fails a command.
|
|
231
|
+
*/
|
|
232
|
+
async function withManage(resolved, fn) {
|
|
233
|
+
let managementKey = (await loadMockDbKeys(resolved.agentId, resolved.homeDir))
|
|
234
|
+
.managementKey;
|
|
235
|
+
if (!managementKey) {
|
|
236
|
+
managementKey = await tryMintManagementKey(resolved);
|
|
237
|
+
if (!managementKey)
|
|
238
|
+
return fn(resolved.tokenAuth);
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
return await fn({ mode: "mgmt", managementKey });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (!isAuthRejection(error))
|
|
245
|
+
throw error;
|
|
246
|
+
const fresh = await tryMintManagementKey(resolved);
|
|
247
|
+
if (fresh) {
|
|
248
|
+
try {
|
|
249
|
+
return await fn({ mode: "mgmt", managementKey: fresh });
|
|
250
|
+
}
|
|
251
|
+
catch (retryError) {
|
|
252
|
+
if (isAuthRejection(retryError))
|
|
253
|
+
return fn(resolved.tokenAuth);
|
|
254
|
+
throw retryError;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return fn(resolved.tokenAuth);
|
|
82
258
|
}
|
|
83
259
|
}
|
|
84
260
|
export async function provisionMockDb(context, options = {}) {
|
|
85
261
|
const resolved = resolveMockDb(context, options);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
262
|
+
// Retry ONLY a pure transport blank. A real `created:false` (the DB already
|
|
263
|
+
// exists — keys not re-shown) is a legitimate, keyed response and must NOT
|
|
264
|
+
// be retried. Provision is idempotent, so retrying a blank is safe.
|
|
265
|
+
const resp = await retryWhile(isEmptyReadObject, () => resolved.client.provision(resolved.agentId, resolved.tokenAuth));
|
|
266
|
+
if (isEmptyReadObject(resp)) {
|
|
267
|
+
throw new CliError(`mockdb provision: the mock-db service (${context.baseUrls.mockDb}) returned HTTP 2xx but a blank body for agent ${resolved.agentId}, even after retries.`, {
|
|
268
|
+
code: "API_SERVER_ERROR",
|
|
269
|
+
suggestion: "Transient backend overload (an empty 2xx body). The DB may or may not have been created — wait a moment, then run `apollo mockdb status`; if it exists, mint keys with `apollo mockdb rotate runtime` / `rotate mgmt`. Do NOT blindly re-provision.",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
await persistReturnedKeys(resolved, resp);
|
|
273
|
+
const keys = await loadMockDbKeys(resolved.agentId, resolved.homeDir);
|
|
274
|
+
const stored = keyPrefixes(keys);
|
|
275
|
+
// Loud-fail the silent "provisioned but no usable keys" dead-end: when the
|
|
276
|
+
// DB already existed the service returns created:false with NO keys.
|
|
277
|
+
// Without a runtime key, `execute` (and the deployed agent) can't call it.
|
|
278
|
+
if (!keys.runtimeKey) {
|
|
279
|
+
return {
|
|
280
|
+
...resp,
|
|
281
|
+
stored,
|
|
282
|
+
keysAvailable: false,
|
|
283
|
+
keysHint: "No runtime key is stored for this agent (provision returned none — the DB already existed). Run `apollo mockdb rotate runtime` to mint one, then `apollo mockdb wire` and update the connection's token env. `execute` self-heals by minting on demand.",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return { ...resp, stored, keysAvailable: true };
|
|
90
287
|
}
|
|
91
288
|
export async function describeMockDb(context, options = {}) {
|
|
92
289
|
const resolved = resolveMockDb(context, options);
|
|
93
|
-
return resolved.client.describe(resolved.agentId,
|
|
290
|
+
return readWithRetry(resolved, "describe", () => withManage(resolved, (auth) => resolved.client.describe(resolved.agentId, auth)));
|
|
94
291
|
}
|
|
95
292
|
export async function statusMockDb(context, options = {}) {
|
|
96
293
|
const resolved = resolveMockDb(context, options);
|
|
97
|
-
const
|
|
98
|
-
const
|
|
294
|
+
const keys = keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir));
|
|
295
|
+
const hasLocalKeys = !!(keys.managementKey || keys.runtimeKey);
|
|
296
|
+
let remote = null;
|
|
297
|
+
let error;
|
|
298
|
+
try {
|
|
299
|
+
const resp = await retryWhile(isEmptyReadObject, () => resolved.client.status(resolved.agentId, resolved.tokenAuth));
|
|
300
|
+
// A blank `{}` (transport blank under load) is NOT "the service doesn't
|
|
301
|
+
// own this agent" — treat it as unreachable → `unknown`, never `new`.
|
|
302
|
+
if (isEmptyReadObject(resp)) {
|
|
303
|
+
throw new Error("/status returned a blank body (transport blank under load)");
|
|
304
|
+
}
|
|
305
|
+
remote = resp;
|
|
306
|
+
}
|
|
307
|
+
catch (probeError) {
|
|
308
|
+
error = probeError instanceof Error ? probeError.message : String(probeError);
|
|
309
|
+
}
|
|
310
|
+
let verdict;
|
|
311
|
+
let message;
|
|
312
|
+
if (!remote) {
|
|
313
|
+
verdict = "unknown";
|
|
314
|
+
message =
|
|
315
|
+
"The mock-db /status check was unreachable — nothing was changed. Retry, or run `apollo mockdb describe` to confirm.";
|
|
316
|
+
}
|
|
317
|
+
else if (remote.exists && remote.hasData !== false) {
|
|
318
|
+
verdict = "ok";
|
|
319
|
+
message =
|
|
320
|
+
"The mock-db service owns this agent's mock DB" +
|
|
321
|
+
(remote.hasData ? " and it has data." : ".");
|
|
322
|
+
}
|
|
323
|
+
else if (remote.exists) {
|
|
324
|
+
verdict = "empty";
|
|
325
|
+
message =
|
|
326
|
+
"The mock-db service owns this agent's record but it is EMPTY (no collections or endpoints). Seed it with `apollo mockdb collections create` / `seed` / `endpoint create`.";
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
verdict = "new";
|
|
330
|
+
message = hasLocalKeys
|
|
331
|
+
? "The mock-db service does NOT own this agent (no mock DB), yet local keys exist — they belong to a DB that was deleted or lives on another deployment. Run `apollo mockdb provision` to create one here; the stale keys are replaced on provision."
|
|
332
|
+
: "No mock DB for this agent yet. Run `apollo mockdb provision` to create one.";
|
|
333
|
+
}
|
|
99
334
|
return {
|
|
100
335
|
agentId: resolved.agentId,
|
|
336
|
+
backend: "aui-managed",
|
|
101
337
|
baseUrl: context.baseUrls.mockDb,
|
|
102
338
|
remote,
|
|
103
|
-
keys
|
|
339
|
+
keys,
|
|
340
|
+
verdict,
|
|
341
|
+
message,
|
|
342
|
+
...(error === undefined ? {} : { error }),
|
|
104
343
|
};
|
|
105
344
|
}
|
|
106
345
|
export async function createCollection(context, name, schema, options = {}) {
|
|
107
346
|
const resolved = resolveMockDb(context, options);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
347
|
+
// No auto-retry on a write: a blank 2xx may mean the create already landed.
|
|
348
|
+
const resp = await withManage(resolved, (auth) => resolved.client.createCollection(resolved.agentId, auth, { name, schema }));
|
|
349
|
+
if (isEmptyReadObject(resp))
|
|
350
|
+
blankBodyError(resolved, "collections create", "write");
|
|
351
|
+
return resp;
|
|
112
352
|
}
|
|
113
353
|
export async function listCollections(context, options = {}) {
|
|
114
354
|
const resolved = resolveMockDb(context, options);
|
|
115
|
-
return resolved.client.listCollections(resolved.agentId,
|
|
355
|
+
return readWithRetry(resolved, "collections list", () => withManage(resolved, (auth) => resolved.client.listCollections(resolved.agentId, auth)));
|
|
116
356
|
}
|
|
117
357
|
export async function updateCollection(context, name, addColumns, options = {}) {
|
|
118
358
|
const resolved = resolveMockDb(context, options);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
359
|
+
const resp = await withManage(resolved, (auth) => resolved.client.updateCollection(resolved.agentId, auth, name, { addColumns }));
|
|
360
|
+
if (isEmptyReadObject(resp))
|
|
361
|
+
blankBodyError(resolved, "collections update", "write");
|
|
362
|
+
return resp;
|
|
122
363
|
}
|
|
123
364
|
export async function seedRows(context, collection, rows, options = {}) {
|
|
124
365
|
const resolved = resolveMockDb(context, options);
|
|
125
|
-
|
|
366
|
+
// Seeding APPENDS rows — never auto-retry a blank (a repeat could duplicate).
|
|
367
|
+
const resp = await withManage(resolved, (auth) => resolved.client.seedRows(resolved.agentId, auth, collection, rows));
|
|
368
|
+
if (isEmptyReadObject(resp))
|
|
369
|
+
blankBodyError(resolved, "seed", "write");
|
|
370
|
+
return resp;
|
|
126
371
|
}
|
|
127
372
|
export async function createEndpoint(context, body, options = {}) {
|
|
128
373
|
const resolved = resolveMockDb(context, options);
|
|
129
|
-
|
|
374
|
+
const resp = await withManage(resolved, (auth) => resolved.client.createEndpoint(resolved.agentId, auth, body));
|
|
375
|
+
if (isEmptyReadObject(resp))
|
|
376
|
+
blankBodyError(resolved, "endpoint create", "write");
|
|
377
|
+
return resp;
|
|
130
378
|
}
|
|
131
379
|
export async function listEndpoints(context, options = {}) {
|
|
132
380
|
const resolved = resolveMockDb(context, options);
|
|
133
|
-
return resolved.client.listEndpoints(resolved.agentId,
|
|
381
|
+
return readWithRetry(resolved, "endpoint list", () => withManage(resolved, (auth) => resolved.client.listEndpoints(resolved.agentId, auth)));
|
|
134
382
|
}
|
|
135
383
|
export async function getEndpoint(context, slug, options = {}) {
|
|
136
384
|
const resolved = resolveMockDb(context, options);
|
|
137
|
-
return resolved.client.getEndpoint(resolved.agentId,
|
|
385
|
+
return readWithRetry(resolved, `endpoint get ${slug}`, () => withManage(resolved, (auth) => resolved.client.getEndpoint(resolved.agentId, auth, slug)));
|
|
138
386
|
}
|
|
139
387
|
export async function updateEndpoint(context, slug, body, options = {}) {
|
|
140
388
|
const resolved = resolveMockDb(context, options);
|
|
141
|
-
|
|
389
|
+
const resp = await withManage(resolved, (auth) => resolved.client.updateEndpoint(resolved.agentId, auth, slug, body));
|
|
390
|
+
if (isEmptyReadObject(resp))
|
|
391
|
+
blankBodyError(resolved, `endpoint update ${slug}`, "write");
|
|
392
|
+
return resp;
|
|
142
393
|
}
|
|
143
394
|
export async function deleteEndpoint(context, slug, options = {}) {
|
|
144
395
|
const resolved = resolveMockDb(context, options);
|
|
145
|
-
|
|
396
|
+
const resp = await withManage(resolved, (auth) => resolved.client.deleteEndpoint(resolved.agentId, auth, slug));
|
|
397
|
+
if (isEmptyReadObject(resp))
|
|
398
|
+
blankBodyError(resolved, `endpoint delete ${slug}`, "write");
|
|
399
|
+
return resp;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Ordered runtime keys to try. The wired key (`$MOCKDB_TOKEN`, or the
|
|
403
|
+
* connection's `token_env`) is what a local `chat --local` run resolves, so
|
|
404
|
+
* it goes first; the cache holds the last key the console minted/stored and
|
|
405
|
+
* is the fallback. Duplicates collapse. `executeEndpoint` tries these in
|
|
406
|
+
* order, falls through on a 401/403, and mints only when none authenticate.
|
|
407
|
+
*/
|
|
408
|
+
async function runtimeKeyCandidates(resolved) {
|
|
409
|
+
const envKey = process.env[resolved.tokenEnv]?.trim();
|
|
410
|
+
const cacheKey = (await loadMockDbKeys(resolved.agentId, resolved.homeDir)).runtimeKey;
|
|
411
|
+
const candidates = [];
|
|
412
|
+
if (envKey)
|
|
413
|
+
candidates.push({ key: envKey, source: "env" });
|
|
414
|
+
if (cacheKey && cacheKey !== envKey)
|
|
415
|
+
candidates.push({ key: cacheKey, source: "cache" });
|
|
416
|
+
return candidates;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* When `execute` had to mint a fresh key — or the wired env key was rejected
|
|
420
|
+
* and a cached one worked — the key the deployed agent / local runtime uses
|
|
421
|
+
* is stale. The console doesn't edit secrets itself, so it attaches a loud,
|
|
422
|
+
* actionable `runtimeKeyResync` block carrying the working key + the exact
|
|
423
|
+
* re-wire instruction. The command layer echoes it; tooling reads the JSON.
|
|
424
|
+
*/
|
|
425
|
+
function attachRuntimeKeyResync(resolved, resp, source, runtimeKey) {
|
|
426
|
+
const reason = source === "cache"
|
|
427
|
+
? `the runtime key in $${resolved.tokenEnv} was rejected (HTTP 401); a working key was recovered from the local cache`
|
|
428
|
+
: "no valid runtime key was wired or cached; a fresh runtime key was minted (any previously wired key is now invalid)";
|
|
429
|
+
if (resp && typeof resp === "object" && !Array.isArray(resp)) {
|
|
430
|
+
const block = {
|
|
431
|
+
required: true,
|
|
432
|
+
source,
|
|
433
|
+
reason,
|
|
434
|
+
runtimeKey,
|
|
435
|
+
action: `Export ${resolved.tokenEnv}=${runtimeKey} (and update the vault secret / secrets.yaml the connection resolves it from), ` +
|
|
436
|
+
"then re-run `apollo mockdb wire` if connections.yaml changed and `apollo push`. Until then the DEPLOYED agent keeps getting HTTP 401 on its runtime calls.",
|
|
437
|
+
};
|
|
438
|
+
resp.runtimeKeyResync = block;
|
|
439
|
+
}
|
|
440
|
+
return resp;
|
|
441
|
+
}
|
|
442
|
+
// Read-after-write lag: a write commits, but the next read can be served by a
|
|
443
|
+
// not-yet-caught-up replica — a 422 no_results (row not visible yet) or a
|
|
444
|
+
// transient 5xx. Retried with bounded backoff where a retry can NEVER
|
|
445
|
+
// duplicate a write:
|
|
446
|
+
// • 422 no_results ⇒ the service matched 0 rows ⇒ NOTHING was mutated
|
|
447
|
+
// (an insert always returns count 1, so it never 422s). Always safe.
|
|
448
|
+
// • a transient 5xx / dropped connection is ambiguous for a write, so it is
|
|
449
|
+
// retried ONLY for non-insert endpoints (read/update/delete are idempotent);
|
|
450
|
+
// an insert — or an endpoint whose kind can't be confirmed — surfaces at once.
|
|
451
|
+
const NO_RESULTS_MAX_RETRIES = 2;
|
|
452
|
+
const TRANSIENT_MAX_RETRIES = 4;
|
|
453
|
+
const LAG_BACKOFF_MS = [300, 800, 1800, 3000];
|
|
454
|
+
function lagBackoffMs(attempt) {
|
|
455
|
+
const base = LAG_BACKOFF_MS[Math.min(attempt, LAG_BACKOFF_MS.length) - 1] ?? 3000;
|
|
456
|
+
return base + Math.floor(Math.random() * 150);
|
|
457
|
+
}
|
|
458
|
+
const endpointKindCache = new Map();
|
|
459
|
+
/** Best-effort endpoint kind via the management plane (cached per process).
|
|
460
|
+
* Unknown ⇒ callers MUST treat as "not safe to retry a 5xx". */
|
|
461
|
+
async function resolveEndpointKind(resolved, slug) {
|
|
462
|
+
const cacheKey = `${resolved.agentId}:${slug}`;
|
|
463
|
+
const cached = endpointKindCache.get(cacheKey);
|
|
464
|
+
if (cached)
|
|
465
|
+
return cached;
|
|
466
|
+
try {
|
|
467
|
+
const resp = await withManage(resolved, (auth) => resolved.client.getEndpoint(resolved.agentId, auth, slug));
|
|
468
|
+
const kind = resp?.kind;
|
|
469
|
+
if (kind === "read" || kind === "insert" || kind === "update" || kind === "delete") {
|
|
470
|
+
endpointKindCache.set(cacheKey, kind);
|
|
471
|
+
return kind;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// unknown → unsafe for a 5xx retry (the 422 retry is still allowed)
|
|
476
|
+
}
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
function isNoResultsError(error) {
|
|
480
|
+
return (error instanceof ApiError && error.status === 422 && mockDbErrorCode(error) === "no_results");
|
|
481
|
+
}
|
|
482
|
+
function isTransientServerError(error) {
|
|
483
|
+
if (error instanceof ApiError)
|
|
484
|
+
return error.status >= 500 && error.status <= 599;
|
|
485
|
+
return isMockDbTransportError(error);
|
|
486
|
+
}
|
|
487
|
+
/** Mint + cache a fresh runtime key (loud-fails on a keyless mint). */
|
|
488
|
+
async function mintRuntimeKey(resolved) {
|
|
489
|
+
const resp = await rotateWithRetry(resolved, "runtimeKey", "rotate runtime", () => withManage(resolved, (auth) => resolved.client.rotateRuntimeKey(resolved.agentId, auth)));
|
|
490
|
+
// rotateWithRetry loud-fails unless the key came back, so it is present here.
|
|
491
|
+
return resp.runtimeKey;
|
|
146
492
|
}
|
|
147
493
|
export async function executeEndpoint(context, slug, input = {}, options = {}) {
|
|
148
494
|
const resolved = resolveMockDb(context, options);
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
495
|
+
const sessionKey = input.session?.trim() || undefined;
|
|
496
|
+
const candidates = await runtimeKeyCandidates(resolved);
|
|
497
|
+
const runOnce = (key) => {
|
|
498
|
+
const auth = {
|
|
499
|
+
mode: "runtime",
|
|
500
|
+
runtimeKey: key,
|
|
501
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
502
|
+
};
|
|
503
|
+
return resolved.client.execute(resolved.agentId, auth, slug, input.body);
|
|
504
|
+
};
|
|
505
|
+
// A WRITE always requires a session (the service rejects a write with no
|
|
506
|
+
// session key), so an execute WITHOUT a session is guaranteed a read and is
|
|
507
|
+
// safe to retry on a transient blank / 5xx; WITH a session the kind gates it.
|
|
508
|
+
let transientRetrySafe;
|
|
509
|
+
const transientIsSafe = async () => {
|
|
510
|
+
if (sessionKey === undefined)
|
|
511
|
+
return true;
|
|
512
|
+
if (transientRetrySafe === undefined) {
|
|
513
|
+
const kind = await resolveEndpointKind(resolved, slug);
|
|
514
|
+
transientRetrySafe = kind !== undefined && kind !== "insert";
|
|
515
|
+
}
|
|
516
|
+
return transientRetrySafe;
|
|
157
517
|
};
|
|
158
|
-
|
|
518
|
+
const inTest = inTestEnv();
|
|
519
|
+
const runWithRetries = async (key) => {
|
|
520
|
+
const attempt = () => sessionKey === undefined
|
|
521
|
+
? retryWhile(isEmptyReadObject, () => runOnce(key))
|
|
522
|
+
: runOnce(key);
|
|
523
|
+
let noResultsTries = 0;
|
|
524
|
+
let transientTries = 0;
|
|
525
|
+
for (;;) {
|
|
526
|
+
try {
|
|
527
|
+
return await attempt();
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
if (isAuthRejection(error))
|
|
531
|
+
throw error; // next key source / mint
|
|
532
|
+
if (isNoResultsError(error) && noResultsTries < NO_RESULTS_MAX_RETRIES) {
|
|
533
|
+
if (!inTest)
|
|
534
|
+
await sleep(lagBackoffMs(++noResultsTries));
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (isTransientServerError(error) &&
|
|
538
|
+
transientTries < TRANSIENT_MAX_RETRIES &&
|
|
539
|
+
(await transientIsSafe())) {
|
|
540
|
+
if (!inTest)
|
|
541
|
+
await sleep(lagBackoffMs(++transientTries));
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
let resp;
|
|
549
|
+
let usedSource;
|
|
550
|
+
let usedKey;
|
|
551
|
+
let envRejected = false;
|
|
552
|
+
for (const candidate of candidates) {
|
|
553
|
+
try {
|
|
554
|
+
resp = await runWithRetries(candidate.key);
|
|
555
|
+
usedSource = candidate.source;
|
|
556
|
+
usedKey = candidate.key;
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (isAuthRejection(error)) {
|
|
561
|
+
if (candidate.source === "env")
|
|
562
|
+
envRejected = true;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (usedSource === undefined) {
|
|
569
|
+
const minted = await mintRuntimeKey(resolved);
|
|
570
|
+
try {
|
|
571
|
+
resp = await runWithRetries(minted);
|
|
572
|
+
usedSource = "minted";
|
|
573
|
+
usedKey = minted;
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
if (isAuthRejection(error)) {
|
|
577
|
+
throw new CliError(`mockdb execute ${slug}: the runtime key is still invalid for agent ${resolved.agentId} even after minting a fresh one (HTTP ${error.status}).`, {
|
|
578
|
+
code: "API_CLIENT_ERROR",
|
|
579
|
+
suggestion: "Confirm the agent is provisioned with `apollo mockdb status`; the backend may be rejecting freshly-minted keys (report it).",
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (isEmptyReadObject(resp)) {
|
|
586
|
+
blankBodyError(resolved, `execute ${slug}`, sessionKey === undefined ? "read" : "write");
|
|
587
|
+
}
|
|
588
|
+
// A minted key, or a cached key that had to replace a rejected wired key,
|
|
589
|
+
// means whatever the runtime resolves is stale ⇒ surface the re-wire step.
|
|
590
|
+
if (usedKey && (usedSource === "minted" || (usedSource === "cache" && envRejected))) {
|
|
591
|
+
resp = attachRuntimeKeyResync(resolved, resp, usedSource, usedKey);
|
|
592
|
+
}
|
|
593
|
+
return resp;
|
|
594
|
+
}
|
|
595
|
+
// ─── Identities (persona sample data for the playground simulator) ──────
|
|
596
|
+
export async function getIdentities(context, options = {}) {
|
|
597
|
+
const resolved = resolveMockDb(context, options);
|
|
598
|
+
// An agent with no personas returns a keyed `{ sample_data: { personas: [] } }`
|
|
599
|
+
// (the feature is simply off), never `{}` — so a blank body really is a
|
|
600
|
+
// transport blank and is retried like every other read.
|
|
601
|
+
return readWithRetry(resolved, "identities get", () => withManage(resolved, (auth) => resolved.client.getIdentities(resolved.agentId, auth)));
|
|
602
|
+
}
|
|
603
|
+
export async function setIdentities(context, sampleData, options = {}) {
|
|
604
|
+
const resolved = resolveMockDb(context, options);
|
|
605
|
+
const resp = await withManage(resolved, (auth) => resolved.client.setIdentities(resolved.agentId, auth, sampleData));
|
|
606
|
+
if (isEmptyReadObject(resp))
|
|
607
|
+
blankBodyError(resolved, "identities set", "write");
|
|
608
|
+
return resp;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Normalize a persona payload into the `sample_data` object the service
|
|
612
|
+
* stores. Accepts the bare object (`{ personas: [...] }`) or the full request
|
|
613
|
+
* body (`{ sample_data: { personas: [...] } }`), so a payload copied straight
|
|
614
|
+
* from the API docs works as-is. Only the outer shape is checked here — the
|
|
615
|
+
* field-level rules belong to the service, which reports them as precise
|
|
616
|
+
* 400s naming the offending `personas[i].fields[j]`.
|
|
617
|
+
*/
|
|
618
|
+
export function normalizeSampleData(raw) {
|
|
619
|
+
const wrapped = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
620
|
+
? raw.sample_data
|
|
621
|
+
: undefined;
|
|
622
|
+
const payload = wrapped !== undefined ? wrapped : raw;
|
|
623
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
624
|
+
throw new ValidationError("The persona payload must be a JSON object (optionally wrapped in `sample_data`).", 'e.g. --sample-data \'{"personas":[{"label":"Jane Doe (verified)","state":"verified","default":true,"fields":[]}]}\'');
|
|
625
|
+
}
|
|
626
|
+
if (!Array.isArray(payload.personas)) {
|
|
627
|
+
throw new ValidationError("The persona payload needs a `personas` array (pass an empty array to turn the persona picker off).", 'Each persona needs `label`, `state`, and `fields`; exactly one must have `default: true`. e.g. \'{"personas":[{"label":"Jane Doe (verified)","state":"verified","default":true,"fields":[{"key":"user_id","label":"User ID","value":"U1001","group":"who_you_are"}]}]}\'');
|
|
628
|
+
}
|
|
629
|
+
return payload;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Read the persona payload from an inline JSON string or a file. A persona
|
|
633
|
+
* payload is the largest JSON any mockdb command takes and its values
|
|
634
|
+
* routinely contain apostrophes, so the file path avoids shell quoting.
|
|
635
|
+
*/
|
|
636
|
+
export async function readSampleDataInput(input) {
|
|
637
|
+
const inline = input.sampleData?.trim();
|
|
638
|
+
const file = input.file?.trim();
|
|
639
|
+
if (inline && file) {
|
|
640
|
+
throw new ValidationError("Pass either --sample-data or --file, not both.", "Use --file <path.json> for a large payload, --sample-data '<json>' for a small inline one.");
|
|
641
|
+
}
|
|
642
|
+
if (file) {
|
|
643
|
+
const resolvedPath = path.resolve(file);
|
|
644
|
+
let raw;
|
|
645
|
+
try {
|
|
646
|
+
raw = await readFile(resolvedPath, "utf8");
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
throw new ValidationError(`--file could not be read: ${error instanceof Error ? error.message : String(error)}`, `Check that ${resolvedPath} exists and is readable JSON.`);
|
|
650
|
+
}
|
|
651
|
+
return parseJsonFlag(`--file (${resolvedPath})`, raw);
|
|
652
|
+
}
|
|
653
|
+
if (!inline) {
|
|
654
|
+
throw new ValidationError("Missing required option: --sample-data.", "Pass --sample-data '{\"personas\":[...]}' (or --file <path.json>).");
|
|
655
|
+
}
|
|
656
|
+
return parseJsonFlag("--sample-data", inline);
|
|
159
657
|
}
|
|
658
|
+
// ─── Sessions / keys / export / delete ──────────────────────────────────
|
|
160
659
|
export async function inspectSession(context, sessionKey, options = {}) {
|
|
161
660
|
const resolved = resolveMockDb(context, options);
|
|
162
|
-
|
|
661
|
+
// A real empty overlay returns a keyed `{ sessionKey, totalRecords:0, … }`,
|
|
662
|
+
// never `{}` — a blank is a transport blip, not an empty session.
|
|
663
|
+
return readWithRetry(resolved, `session get ${sessionKey}`, () => withManage(resolved, (auth) => resolved.client.inspectSession(resolved.agentId, auth, sessionKey)));
|
|
163
664
|
}
|
|
164
665
|
export async function resetSession(context, sessionKey, options = {}) {
|
|
165
666
|
const resolved = resolveMockDb(context, options);
|
|
166
|
-
return resolved.client.resetSession(resolved.agentId,
|
|
667
|
+
return withManage(resolved, (auth) => resolved.client.resetSession(resolved.agentId, auth, sessionKey));
|
|
167
668
|
}
|
|
168
669
|
export async function rotateMgmtKey(context, options = {}) {
|
|
169
670
|
const resolved = resolveMockDb(context, options);
|
|
170
|
-
|
|
171
|
-
|
|
671
|
+
// The one rotation that always rides the Apollo token — it is also how a
|
|
672
|
+
// pre-existing agent obtains its FIRST management key.
|
|
673
|
+
const resp = await rotateWithRetry(resolved, "managementKey", "rotate mgmt", () => resolved.client.rotateMgmtKey(resolved.agentId, resolved.tokenAuth));
|
|
172
674
|
return {
|
|
173
675
|
...resp,
|
|
174
676
|
stored: keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
|
|
@@ -176,8 +678,7 @@ export async function rotateMgmtKey(context, options = {}) {
|
|
|
176
678
|
}
|
|
177
679
|
export async function rotateRuntimeKey(context, options = {}) {
|
|
178
680
|
const resolved = resolveMockDb(context, options);
|
|
179
|
-
const resp = await resolved.client.rotateRuntimeKey(resolved.agentId,
|
|
180
|
-
await persistProvisionKeys(resolved.agentId, context.baseUrls.mockDb, resp, resolved.homeDir);
|
|
681
|
+
const resp = await rotateWithRetry(resolved, "runtimeKey", "rotate runtime", () => withManage(resolved, (auth) => resolved.client.rotateRuntimeKey(resolved.agentId, auth)));
|
|
181
682
|
return {
|
|
182
683
|
...resp,
|
|
183
684
|
stored: keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
|
|
@@ -186,10 +687,11 @@ export async function rotateRuntimeKey(context, options = {}) {
|
|
|
186
687
|
}
|
|
187
688
|
export async function exportMockDb(context, options = {}) {
|
|
188
689
|
const resolved = resolveMockDb(context, options);
|
|
189
|
-
return resolved.client.exportAgent(resolved.agentId,
|
|
690
|
+
return readWithRetry(resolved, "export", () => withManage(resolved, (auth) => resolved.client.exportAgent(resolved.agentId, auth)));
|
|
190
691
|
}
|
|
191
692
|
export async function deleteMockDb(context, options = {}) {
|
|
192
693
|
const resolved = resolveMockDb(context, options);
|
|
694
|
+
// DELETE is strict on the service — always the Apollo token, never a mgmt key.
|
|
193
695
|
const resp = await resolved.client.deleteAgent(resolved.agentId, resolved.tokenAuth);
|
|
194
696
|
await clearMockDbKeys(resolved.agentId, resolved.homeDir);
|
|
195
697
|
return resp;
|
|
@@ -201,6 +703,32 @@ export async function storedKeys(context, options = {}) {
|
|
|
201
703
|
...keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
|
|
202
704
|
};
|
|
203
705
|
}
|
|
706
|
+
/**
|
|
707
|
+
* Clone another agent's mock DB (collections + base rows + endpoints +
|
|
708
|
+
* personas) into THIS agent. The target must be empty — the service refuses
|
|
709
|
+
* (400) to overwrite an existing mock DB. Both agents are authorized on the
|
|
710
|
+
* account plane (the caller must be a member of the account owning each).
|
|
711
|
+
* Keys come back only when the target was provisioned by this call; they are
|
|
712
|
+
* persisted like a provision.
|
|
713
|
+
*/
|
|
714
|
+
export async function cloneMockDb(context, options) {
|
|
715
|
+
const resolved = resolveMockDb(context, options);
|
|
716
|
+
const from = options.from.trim();
|
|
717
|
+
if (!from) {
|
|
718
|
+
throw new ValidationError("Missing clone source.", "Pass --from <sourceAgentId>.");
|
|
719
|
+
}
|
|
720
|
+
if (from === resolved.agentId) {
|
|
721
|
+
throw new ValidationError("The clone source and target are the same agent.", "Pass --from <a different agent id> (e.g. the template agent this one was created from).");
|
|
722
|
+
}
|
|
723
|
+
const resp = await retryWhile(isEmptyReadObject, () => resolved.client.cloneAgent(resolved.agentId, from, resolved.tokenAuth));
|
|
724
|
+
if (isEmptyReadObject(resp))
|
|
725
|
+
blankBodyError(resolved, `clone from ${from}`, "write");
|
|
726
|
+
await persistReturnedKeys(resolved, resp);
|
|
727
|
+
return {
|
|
728
|
+
...resp,
|
|
729
|
+
stored: keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
204
732
|
/**
|
|
205
733
|
* Ensure the bundle's `connections.yaml` has a mock-db connection pointing at
|
|
206
734
|
* this agent's execute base, reading the runtime key from MOCKDB_TOKEN (vault
|
|
@@ -220,10 +748,10 @@ export async function wireMockDbConnection(context, options = {}) {
|
|
|
220
748
|
}
|
|
221
749
|
const keys = await loadMockDbKeys(resolved.agentId, resolved.homeDir);
|
|
222
750
|
if (!keys.runtimeKey) {
|
|
223
|
-
throw new ConfigError("No runtime key to wire.", "Run `apollo mockdb provision` first.");
|
|
751
|
+
throw new ConfigError("No runtime key to wire.", "Run `apollo mockdb provision` first (or `apollo mockdb rotate runtime` for an existing DB).");
|
|
224
752
|
}
|
|
225
753
|
const connectionId = options.connectionId ?? "mock-db";
|
|
226
|
-
const tokenEnv = options.tokenEnv ??
|
|
754
|
+
const tokenEnv = options.tokenEnv ?? DEFAULT_MOCKDB_TOKEN_ENV;
|
|
227
755
|
const baseUrl = `${context.baseUrls.mockDb.replace(/\/+$/, "")}/api/agents/${resolved.agentId}/apis`;
|
|
228
756
|
const sourceRoot = path.join(agentRoot, "bundle", "src");
|
|
229
757
|
const file = path.join(sourceRoot, "connections.yaml");
|
|
@@ -287,6 +815,7 @@ export async function wireMockDbConnection(context, options = {}) {
|
|
|
287
815
|
notes,
|
|
288
816
|
};
|
|
289
817
|
}
|
|
818
|
+
// ─── Flag parsing helpers (shared by the CLI and the TUI) ───────────────
|
|
290
819
|
export function parseJsonFlag(flag, raw) {
|
|
291
820
|
try {
|
|
292
821
|
return JSON.parse(raw);
|
|
@@ -303,4 +832,180 @@ export function normalizeSchema(raw) {
|
|
|
303
832
|
}
|
|
304
833
|
throw new ValidationError("--schema must be a JSON array of columns, or { \"columns\": [...] }.");
|
|
305
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* Normalize the `--add-columns` value into a column list. Accepts a bare array
|
|
837
|
+
* of column defs, or a `{ columns: [...] }` / `{ addColumns: [...] }` wrapper
|
|
838
|
+
* so the shape matches `collections create --schema`.
|
|
839
|
+
*/
|
|
840
|
+
export function normalizeAddColumns(raw) {
|
|
841
|
+
const arr = Array.isArray(raw)
|
|
842
|
+
? raw
|
|
843
|
+
: raw && typeof raw === "object"
|
|
844
|
+
? (raw.addColumns ??
|
|
845
|
+
raw.columns)
|
|
846
|
+
: undefined;
|
|
847
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
848
|
+
throw new ValidationError("--add-columns must be a non-empty JSON array of column definitions (or an object with a `columns` array).", 'e.g. --add-columns \'[{"name":"status","type":"text"}]\'. Added columns are nullable; primary keys cannot be added to an existing collection.');
|
|
849
|
+
}
|
|
850
|
+
return arr;
|
|
851
|
+
}
|
|
852
|
+
// ─── Guide ──────────────────────────────────────────────────────────────
|
|
853
|
+
/** The text emitted by `apollo mockdb guide` — the how-to for driving the mock DB. */
|
|
854
|
+
export const MOCKDB_GUIDE = `# Mock DB — Apollo CLI Guide
|
|
855
|
+
|
|
856
|
+
A per-agent **mock database** for building and testing agents that have no
|
|
857
|
+
real backend (or no test environment) yet. You define collections (tables),
|
|
858
|
+
seed realistic data, and declare named read/write endpoints; the agent's
|
|
859
|
+
program sources then call those endpoints at runtime through a
|
|
860
|
+
\`connections.yaml\` connection. It supports relative date/time fields
|
|
861
|
+
(\`now-30d\`) that stay fresh, and per-conversation copy-on-write keyed by the
|
|
862
|
+
session id, so test runs are isolated and resettable.
|
|
863
|
+
|
|
864
|
+
All \`apollo mockdb\` commands are **non-interactive**. Pass the global
|
|
865
|
+
\`--json\` for the machine envelope (\`{ "success": true, "data": ... }\`).
|
|
866
|
+
|
|
867
|
+
## How auth works (and why it's safe)
|
|
868
|
+
|
|
869
|
+
- The agent comes from the checkout you are standing in (\`.apollorc\`) or
|
|
870
|
+
\`--agent <id>\`; the account from the active project (\`--account <id>\` or
|
|
871
|
+
\`APOLLO_ACCOUNT_ID\` to override).
|
|
872
|
+
- Your Apollo token is sent **first-party from the CLI** only where the
|
|
873
|
+
service demands it: \`provision\`, \`status\`, \`delete\`, and minting a
|
|
874
|
+
management key. The service verifies it against Apollo (you must be a
|
|
875
|
+
member of the agent's account).
|
|
876
|
+
- Provision returns two per-agent keys, stored under \`~/.apollo/mockdb/\`:
|
|
877
|
+
- **managementKey** — used for ALL other management calls (\`X-Mgmt-Key\`;
|
|
878
|
+
no Apollo token transmitted). Minted on demand if missing/rejected.
|
|
879
|
+
- **runtimeKey** — what the agent's connection uses at runtime; also what
|
|
880
|
+
\`apollo mockdb execute\` uses to test endpoints.
|
|
881
|
+
|
|
882
|
+
## Typical workflow
|
|
883
|
+
|
|
884
|
+
\`\`\`bash
|
|
885
|
+
# 1. Provision (stores the keys) and wire bundle/src/connections.yaml
|
|
886
|
+
apollo mockdb provision --wire
|
|
887
|
+
|
|
888
|
+
# 2. Create collections (parents before children — FKs need the parent first)
|
|
889
|
+
apollo mockdb collections create --name users \\
|
|
890
|
+
--schema '{"columns":[{"name":"id","type":"text","primaryKey":true},{"name":"name","type":"text"}]}'
|
|
891
|
+
apollo mockdb collections create --name accounts \\
|
|
892
|
+
--schema '{"columns":[{"name":"id","type":"text","primaryKey":true},{"name":"user_id","type":"text","references":"users.id"},{"name":"balance","type":"number"},{"name":"opened_at","type":"reldate"}]}'
|
|
893
|
+
|
|
894
|
+
# 3. Seed data (relative date tokens allowed)
|
|
895
|
+
apollo mockdb seed --collection users --rows '[{"id":"U1","name":"Ada Lovelace"}]'
|
|
896
|
+
apollo mockdb seed --collection accounts --rows '[{"id":"A1","user_id":"U1","balance":4200.5,"opened_at":"now-400d"}]'
|
|
897
|
+
|
|
898
|
+
# 4. Declare the endpoints the agent will call
|
|
899
|
+
apollo mockdb endpoint create --slug get-accounts \\
|
|
900
|
+
--param-schema '{"user_id":{"type":"string","required":true}}' \\
|
|
901
|
+
--spec '{"kind":"read","collection":"accounts","where":[{"column":"user_id","op":"eq","param":"user_id"}]}'
|
|
902
|
+
|
|
903
|
+
# 5. Test an endpoint under a session (runtime key + session = a conversation)
|
|
904
|
+
apollo mockdb execute --slug get-accounts --session test-1 --body '{"user_id":"U1"}'
|
|
905
|
+
|
|
906
|
+
# 6. Inspect / reset that session's overlay
|
|
907
|
+
apollo mockdb session get --key test-1
|
|
908
|
+
apollo mockdb session reset --key test-1
|
|
909
|
+
\`\`\`
|
|
910
|
+
|
|
911
|
+
Then map each program source that names \`connection: mock-db\` to an endpoint
|
|
912
|
+
under \`operations\` in \`bundle/src/connections.yaml\`, export the runtime key as
|
|
913
|
+
\`MOCKDB_TOKEN\` (or the connection's \`token_env\`), and \`apollo validate &&
|
|
914
|
+
apollo push\`.
|
|
915
|
+
|
|
916
|
+
## Personas for the playground simulator (\`identities\`)
|
|
917
|
+
|
|
918
|
+
Once the mock DB has data, the playground can offer a **persona picker** before
|
|
919
|
+
the first message so a tester speaks as a real seeded user. The payload is
|
|
920
|
+
authored here — the schema and seed values are already in hand:
|
|
921
|
+
|
|
922
|
+
\`\`\`bash
|
|
923
|
+
# Read what's stored (no personas ⇒ { "personas": [] } — the picker is off).
|
|
924
|
+
apollo mockdb identities get
|
|
925
|
+
|
|
926
|
+
# Upsert the payload (large payloads are easier from a file).
|
|
927
|
+
apollo mockdb identities set --file personas.json
|
|
928
|
+
apollo mockdb identities set --sample-data '{
|
|
929
|
+
"instructions": "Pick a persona to simulate as.",
|
|
930
|
+
"personas": [
|
|
931
|
+
{
|
|
932
|
+
"label": "Jane Doe (verified)",
|
|
933
|
+
"state": "verified",
|
|
934
|
+
"default": true,
|
|
935
|
+
"fields": [
|
|
936
|
+
{ "key": "user_id", "label": "User ID", "value": "U1001", "group": "who_you_are" },
|
|
937
|
+
{ "key": "pin", "label": "PIN", "value": "4321", "group": "verify", "role": "secret" }
|
|
938
|
+
]
|
|
939
|
+
}
|
|
940
|
+
]
|
|
941
|
+
}'
|
|
942
|
+
\`\`\`
|
|
943
|
+
|
|
944
|
+
Rules the service enforces (a 400 names the exact \`personas[i].fields[j]\`):
|
|
945
|
+
|
|
946
|
+
- Each persona needs a non-empty \`label\` and \`state\`; each field needs
|
|
947
|
+
\`key\`, \`label\`, \`value\`, and a \`group\` of \`who_you_are\` | \`verify\` |
|
|
948
|
+
\`good_to_know\` | \`records\`.
|
|
949
|
+
- Exactly **one** persona carries \`default: true\` (when there is at least one).
|
|
950
|
+
- Optional per-field extras: \`role\` (\`key\` | \`secret\`), \`audience\`
|
|
951
|
+
(\`user\` | \`simulator\` | \`both\`), \`required_to_start\`, \`match\`, \`maps_to\`.
|
|
952
|
+
- Optional per-persona \`intents\` (\`{ title, message }\`) seed suggested openers.
|
|
953
|
+
|
|
954
|
+
\`set\` is a full **REPLACE**, not a merge — \`{"personas":[]}\` turns the picker
|
|
955
|
+
off. Seed the values first: every persona value must exist in the mock DB.
|
|
956
|
+
Personas travel with the DB — \`clone\` and \`export\` carry them.
|
|
957
|
+
|
|
958
|
+
## Cloning another agent's mock DB
|
|
959
|
+
|
|
960
|
+
\`\`\`bash
|
|
961
|
+
# Copy a template agent's collections + rows + endpoints + personas into THIS
|
|
962
|
+
# agent (the target must be empty; your account must own both agents):
|
|
963
|
+
apollo mockdb clone --from <templateAgentId>
|
|
964
|
+
\`\`\`
|
|
965
|
+
|
|
966
|
+
## Commands
|
|
967
|
+
|
|
968
|
+
| Command | What it does |
|
|
969
|
+
|---|---|
|
|
970
|
+
| \`mockdb provision [--wire]\` | Create the agent's mock DB; store runtime + management keys |
|
|
971
|
+
| \`mockdb status\` | Ownership + local key prefixes + verdict (ok / empty / new / unknown) |
|
|
972
|
+
| \`mockdb describe\` | Schema, relationships, relative-field flags, endpoints, row counts |
|
|
973
|
+
| \`mockdb collections create --name <n> --schema <json>\` | Create a collection (table) |
|
|
974
|
+
| \`mockdb collections update --name <n> --add-columns <json>\` | Add nullable columns (no rename/type-change/drop) |
|
|
975
|
+
| \`mockdb collections list\` | List collections |
|
|
976
|
+
| \`mockdb seed --collection <n> --rows <json-array>\` | Seed base rows |
|
|
977
|
+
| \`mockdb endpoint create --slug <s> --spec <json> [--param-schema <json>] [--description <t>]\` | Declare a read/insert/update/delete endpoint |
|
|
978
|
+
| \`mockdb endpoint list\` / \`get <slug>\` | List / inspect endpoints |
|
|
979
|
+
| \`mockdb endpoint update <slug> [--spec] [--param-schema] [--description]\` | PATCH-merge an endpoint |
|
|
980
|
+
| \`mockdb endpoint delete <slug>\` | Delete an endpoint (re-map operations + push after) |
|
|
981
|
+
| \`mockdb execute --slug <s> [--session <key>] [--body <json>]\` | Run an endpoint with the runtime key |
|
|
982
|
+
| \`mockdb identities get\` / \`set --sample-data <json> | --file <path>\` | Read / replace the persona payload |
|
|
983
|
+
| \`mockdb session get --key <key>\` / \`reset --key <key>\` | Inspect / reset a session overlay |
|
|
984
|
+
| \`mockdb rotate mgmt\` / \`rotate runtime\` | Mint fresh keys (re-wire after a runtime rotation) |
|
|
985
|
+
| \`mockdb clone --from <id>\` | Clone another agent's mock DB into this one (target must be empty) |
|
|
986
|
+
| \`mockdb export\` | Export schema + base data + endpoints + personas |
|
|
987
|
+
| \`mockdb keys\` | Show locally stored key prefixes (no secrets) |
|
|
988
|
+
| \`mockdb wire [--connection-id] [--token-env]\` | Write bundle/src/connections.yaml for the runtime key |
|
|
989
|
+
| \`mockdb delete --force\` | Permanently delete the mock DB (Apollo token only) |
|
|
990
|
+
| \`mockdb guide [--output <file>]\` | Write this guide to a file (default GUIDE.md) |
|
|
991
|
+
|
|
992
|
+
Common flags: \`--agent <id>\`, \`--account <id>\`, and the global \`--json\`.
|
|
993
|
+
|
|
994
|
+
## Notes
|
|
995
|
+
|
|
996
|
+
- Create collections **parent-first** (a \`references\` FK needs the parent).
|
|
997
|
+
- **Writes need \`--session\`**; reads may omit it to peek at pristine base.
|
|
998
|
+
- Endpoint \`where\`/value params must be declared in \`--param-schema\`.
|
|
999
|
+
- \`execute\` resolves the runtime key from \`$MOCKDB_TOKEN\` → the local cache →
|
|
1000
|
+
a fresh mint (then reports \`runtimeKeyResync\` so you re-wire).
|
|
1001
|
+
- A blank 2xx from the service is a transient transport blip: reads retry it,
|
|
1002
|
+
writes stop and tell you to verify before retrying (never duplicate rows).
|
|
1003
|
+
- Override the service URL with \`APOLLO_MOCKDB_URL\`.
|
|
1004
|
+
`;
|
|
1005
|
+
/** Write the guide to `outputPath` (default `GUIDE.md` in the cwd). */
|
|
1006
|
+
export async function writeMockDbGuide(outputPath) {
|
|
1007
|
+
const resolvedPath = path.resolve(outputPath?.trim() || "GUIDE.md");
|
|
1008
|
+
await writeFile(resolvedPath, MOCKDB_GUIDE, "utf8");
|
|
1009
|
+
return { written: true, path: resolvedPath, bytes: Buffer.byteLength(MOCKDB_GUIDE) };
|
|
1010
|
+
}
|
|
306
1011
|
//# sourceMappingURL=mockdb.js.map
|