@embrasure/ember 0.2.0
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/README.md +58 -0
- package/dist/auth.d.ts +24 -0
- package/dist/auth.js +305 -0
- package/dist/catalog.d.ts +180 -0
- package/dist/catalog.js +115 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +167 -0
- package/dist/operator.d.ts +36 -0
- package/dist/operator.js +493 -0
- package/dist/plugin-deletion.d.ts +29 -0
- package/dist/plugin-deletion.js +30 -0
- package/dist/plugin-edits.d.ts +125 -0
- package/dist/plugin-edits.js +73 -0
- package/dist/plugin-policy.d.ts +71 -0
- package/dist/plugin-policy.js +47 -0
- package/dist/program.d.ts +3 -0
- package/dist/program.js +274 -0
- package/dist/query-response.d.ts +31 -0
- package/dist/query-response.js +15 -0
- package/dist/types.d.ts +192 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +60 -0
package/dist/operator.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { EmbrasureApiClient } from "@embrasure/api-client";
|
|
3
|
+
import { readCatalog } from "./catalog.js";
|
|
4
|
+
import { editPluginCatalog, editPluginIngestion } from "./plugin-edits.js";
|
|
5
|
+
import { operatePluginDeletion, isDeletionAction } from "./plugin-deletion.js";
|
|
6
|
+
import { operatePluginPolicy } from "./plugin-policy.js";
|
|
7
|
+
import { queryHistoryResponseSchema, queryResponseSchema, queryResultsResponseSchema } from "./query-response.js";
|
|
8
|
+
const SOURCE_KINDS = new Set(["postgres", "supabase", "salesforce", "stripe", "hubspot", "posthog", "mixpanel", "apollo", "google_sheets", "openapi"]);
|
|
9
|
+
const SECRET_KEY = /(secret|password|credential|token|api[_-]?key|private[_-]?key)/i;
|
|
10
|
+
const SAFE_OPAQUE_KEYS = new Set(["next_token", "nextToken"]);
|
|
11
|
+
const MAX_LIST_ITEMS = 100;
|
|
12
|
+
const MAX_CONNECTOR_LOOKUP_ITEMS = 200;
|
|
13
|
+
const MAX_OUTPUT_BYTES = 64_000;
|
|
14
|
+
const MAX_UPSTREAM_BYTES = 2_097_152;
|
|
15
|
+
const MAX_INGESTION_TABLES = 50;
|
|
16
|
+
function asJson(value) {
|
|
17
|
+
return JSON.parse(JSON.stringify(value));
|
|
18
|
+
}
|
|
19
|
+
function bounded(value) {
|
|
20
|
+
let remaining = MAX_OUTPUT_BYTES;
|
|
21
|
+
let truncated = false;
|
|
22
|
+
const spend = (amount) => { remaining -= amount; if (remaining < 0)
|
|
23
|
+
truncated = true; };
|
|
24
|
+
const walk = (current, key = "", depth = 0) => {
|
|
25
|
+
spend(4);
|
|
26
|
+
if (remaining <= 0 || depth > 12) {
|
|
27
|
+
truncated = true;
|
|
28
|
+
return "[truncated]";
|
|
29
|
+
}
|
|
30
|
+
if (!SAFE_OPAQUE_KEYS.has(key) && SECRET_KEY.test(key))
|
|
31
|
+
return "[redacted]";
|
|
32
|
+
if (typeof current === "string") {
|
|
33
|
+
const text = current.slice(0, Math.min(12_000, remaining));
|
|
34
|
+
spend(text.length);
|
|
35
|
+
if (text.length < current.length)
|
|
36
|
+
truncated = true;
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
if (current === null || ["number", "boolean"].includes(typeof current)) {
|
|
40
|
+
spend(16);
|
|
41
|
+
return current;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(current)) {
|
|
44
|
+
if (current.length > MAX_LIST_ITEMS)
|
|
45
|
+
truncated = true;
|
|
46
|
+
return current.slice(0, MAX_LIST_ITEMS).map((item) => walk(item, "", depth + 1));
|
|
47
|
+
}
|
|
48
|
+
if (typeof current === "object") {
|
|
49
|
+
const entries = Object.entries(current);
|
|
50
|
+
if (entries.length > MAX_LIST_ITEMS)
|
|
51
|
+
truncated = true;
|
|
52
|
+
return Object.fromEntries(entries.slice(0, MAX_LIST_ITEMS).map(([childKey, child]) => {
|
|
53
|
+
const safeKey = childKey.slice(0, 256);
|
|
54
|
+
if (safeKey.length < childKey.length)
|
|
55
|
+
truncated = true;
|
|
56
|
+
spend(safeKey.length);
|
|
57
|
+
return [safeKey, walk(child, safeKey, depth + 1)];
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
return String(current);
|
|
61
|
+
};
|
|
62
|
+
const result = walk(value);
|
|
63
|
+
const object = typeof result === "object" && result !== null && !Array.isArray(result) ? result : { value: result };
|
|
64
|
+
const boundedResult = truncated ? { ...object, _truncated: true } : object;
|
|
65
|
+
const serialized = JSON.stringify(boundedResult);
|
|
66
|
+
if (Buffer.byteLength(serialized, "utf8") <= MAX_OUTPUT_BYTES)
|
|
67
|
+
return boundedResult;
|
|
68
|
+
return {
|
|
69
|
+
_truncated: true,
|
|
70
|
+
message: "Output exceeded the structured response limit. Narrow the request or use pagination.",
|
|
71
|
+
preview: serialized.slice(0, 8_000),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function summarizeIngestion(value) {
|
|
75
|
+
const record = value && typeof value === "object" && !Array.isArray(value)
|
|
76
|
+
? value
|
|
77
|
+
: { value };
|
|
78
|
+
const rawTables = Array.isArray(record.tables) ? record.tables : [];
|
|
79
|
+
const tableCount = typeof record.table_count === "number" ? record.table_count : rawTables.length;
|
|
80
|
+
const statuses = {};
|
|
81
|
+
for (const raw of rawTables) {
|
|
82
|
+
const table = raw && typeof raw === "object" ? raw : {};
|
|
83
|
+
const status = String(table.status ?? "unknown");
|
|
84
|
+
statuses[status] = (statuses[status] ?? 0) + 1;
|
|
85
|
+
}
|
|
86
|
+
const tables = rawTables.slice(0, MAX_INGESTION_TABLES).map((raw) => {
|
|
87
|
+
const table = raw && typeof raw === "object" ? raw : {};
|
|
88
|
+
return {
|
|
89
|
+
id: table.id,
|
|
90
|
+
source_schema: table.source_schema,
|
|
91
|
+
source_table: table.source_table,
|
|
92
|
+
destination_schema: table.destination_schema,
|
|
93
|
+
destination_table: table.destination_table,
|
|
94
|
+
status: table.status,
|
|
95
|
+
cursor_column: table.cursor_column,
|
|
96
|
+
incremental_strategy: table.incremental_strategy,
|
|
97
|
+
primary_key_columns: table.primary_key_columns,
|
|
98
|
+
selected_columns_count: Array.isArray(table.selected_columns) ? table.selected_columns.length : undefined,
|
|
99
|
+
schema_drift_status: table.schema_drift_status,
|
|
100
|
+
schema_change_status: table.schema_change_status,
|
|
101
|
+
lag_seconds: table.lag_seconds,
|
|
102
|
+
rows_inserted: table.rows_inserted,
|
|
103
|
+
rows_updated: table.rows_updated,
|
|
104
|
+
rows_deleted: table.rows_deleted,
|
|
105
|
+
last_synced_at: table.last_synced_at,
|
|
106
|
+
last_error_code: table.last_error_code,
|
|
107
|
+
last_error_message: table.last_error_message,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
const events = (Array.isArray(record.events) ? record.events : []).slice(0, 20).map((raw) => {
|
|
111
|
+
const event = raw && typeof raw === "object" ? raw : {};
|
|
112
|
+
return {
|
|
113
|
+
id: event.id,
|
|
114
|
+
event_type: event.event_type,
|
|
115
|
+
severity: event.severity,
|
|
116
|
+
message: event.message,
|
|
117
|
+
table_id: event.table_id,
|
|
118
|
+
run_id: event.run_id,
|
|
119
|
+
created_at: event.created_at,
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
const { tables: _tables, events: _events, setup_guidance: _setupGuidance, ...summary } = record;
|
|
123
|
+
return {
|
|
124
|
+
...summary,
|
|
125
|
+
table_count: tableCount,
|
|
126
|
+
table_status_counts: statuses,
|
|
127
|
+
tables,
|
|
128
|
+
tables_truncated: tableCount > tables.length,
|
|
129
|
+
recent_events: events,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function withHandle(value, key, fallback) {
|
|
133
|
+
const record = value && typeof value === "object" && !Array.isArray(value) ? value : { value };
|
|
134
|
+
const handle = record[key] ?? record.id ?? fallback;
|
|
135
|
+
const output = bounded(record);
|
|
136
|
+
if (typeof handle !== "string" || !handle || handle.length > 128)
|
|
137
|
+
return output;
|
|
138
|
+
const withHandle = { ...output, [key]: handle };
|
|
139
|
+
return Buffer.byteLength(JSON.stringify(withHandle), "utf8") <= MAX_OUTPUT_BYTES ? withHandle : {
|
|
140
|
+
[key]: handle, _truncated: true, message: "Operation accepted; inspect this handle for details.",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export class EmberOperatorError extends Error {
|
|
144
|
+
code;
|
|
145
|
+
retryable;
|
|
146
|
+
constructor(code, message, retryable = false) {
|
|
147
|
+
super(message);
|
|
148
|
+
this.code = code;
|
|
149
|
+
this.retryable = retryable;
|
|
150
|
+
this.name = "EmberOperatorError";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function invalidQueryResponse(action) {
|
|
154
|
+
throw new EmberOperatorError("invalid_api_response", `The API returned an invalid response for query.${action}. No usable result was returned. Inspect query status/history before retrying a run or cancellation; the operation may already have been accepted.`);
|
|
155
|
+
}
|
|
156
|
+
export class EmberOperator {
|
|
157
|
+
client;
|
|
158
|
+
workspaceId;
|
|
159
|
+
constructor(options) {
|
|
160
|
+
if (!options.workspaceId)
|
|
161
|
+
throw new EmberOperatorError("workspace_required", "Select a workspace first.");
|
|
162
|
+
if (!options.accessToken)
|
|
163
|
+
throw new EmberOperatorError("authentication_required", "Log in before using Ember.");
|
|
164
|
+
this.workspaceId = options.workspaceId;
|
|
165
|
+
this.client = new EmbrasureApiClient({
|
|
166
|
+
baseUrl: options.apiBaseUrl,
|
|
167
|
+
accessToken: options.accessToken,
|
|
168
|
+
fetchImpl: async (url, init) => {
|
|
169
|
+
const isReceipt = String(url).endsWith("/operator/tool-events");
|
|
170
|
+
const timeout = AbortSignal.timeout(isReceipt ? 2_000 : options.requestTimeoutMs ?? 60_000);
|
|
171
|
+
const signal = AbortSignal.any([timeout, ...(!isReceipt && options.signal ? [options.signal] : []), ...(init?.signal ? [init.signal] : [])]);
|
|
172
|
+
signal.throwIfAborted();
|
|
173
|
+
const response = await (options.fetchImpl ?? globalThis.fetch)(url, { ...init, signal });
|
|
174
|
+
if (!response.body)
|
|
175
|
+
return response;
|
|
176
|
+
let receivedBytes = 0;
|
|
177
|
+
const body = response.body.pipeThrough(new TransformStream({
|
|
178
|
+
transform(chunk, controller) {
|
|
179
|
+
receivedBytes += chunk.byteLength;
|
|
180
|
+
if (receivedBytes > MAX_UPSTREAM_BYTES) {
|
|
181
|
+
throw new EmberOperatorError("upstream_response_too_large", "The API response exceeded 2 MiB. Narrow the request or reduce its page size. If a change was started, inspect status/history before retrying it.");
|
|
182
|
+
}
|
|
183
|
+
controller.enqueue(chunk);
|
|
184
|
+
},
|
|
185
|
+
}), { signal });
|
|
186
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
187
|
+
},
|
|
188
|
+
clientName: options.clientName ?? "ember",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async execute(request) {
|
|
192
|
+
const startedAt = performance.now();
|
|
193
|
+
try {
|
|
194
|
+
let result;
|
|
195
|
+
switch (request.tool) {
|
|
196
|
+
case "warehouse":
|
|
197
|
+
result = await this.warehouse(request.input);
|
|
198
|
+
break;
|
|
199
|
+
case "sources":
|
|
200
|
+
result = await this.sources(request.input);
|
|
201
|
+
break;
|
|
202
|
+
case "ingestion":
|
|
203
|
+
result = await this.ingestion(request.input);
|
|
204
|
+
break;
|
|
205
|
+
case "query":
|
|
206
|
+
result = await this.query(request.input);
|
|
207
|
+
break;
|
|
208
|
+
case "catalog":
|
|
209
|
+
result = await this.catalog(request.input);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
await this.recordToolEvent(request, result, "success", performance.now() - startedAt);
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
await this.recordToolEvent(request, undefined, "error", performance.now() - startedAt);
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async setup(input) {
|
|
221
|
+
const data = await this.client.request(`/v1/ember/workspaces/${encodeURIComponent(this.workspaceId)}/operator/setup`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
body: { action: input.action, database: input.database ?? "ember" },
|
|
224
|
+
});
|
|
225
|
+
return { ok: true, action: `setup.${input.action}`, data: bounded(data) };
|
|
226
|
+
}
|
|
227
|
+
async status() {
|
|
228
|
+
const data = await this.client.request(`/v1/ember/workspaces/${encodeURIComponent(this.workspaceId)}/operator/status`);
|
|
229
|
+
const output = bounded(data);
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
action: "status",
|
|
233
|
+
data: output,
|
|
234
|
+
next_action: typeof output.next_action === "string" ? output.next_action : undefined,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
async sources(input) {
|
|
238
|
+
if (input.action === "types") {
|
|
239
|
+
const data = await this.client.request(`/v1/workspaces/${encodeURIComponent(this.workspaceId)}/warehouse/ingestion-sources`);
|
|
240
|
+
const items = data.filter((source) => SOURCE_KINDS.has(source.kind) && source.release_stage !== "in_progress");
|
|
241
|
+
return { ok: true, action: "sources.types", data: bounded({ items }) };
|
|
242
|
+
}
|
|
243
|
+
if (input.action === "list") {
|
|
244
|
+
const data = await this.client.listConnectors(this.workspaceId, MAX_LIST_ITEMS, { connectorUsage: "warehouse_ingestion" });
|
|
245
|
+
const truncated = data.length >= MAX_LIST_ITEMS;
|
|
246
|
+
return {
|
|
247
|
+
ok: true, action: "sources.list", data: bounded({ items: data, truncated }),
|
|
248
|
+
...(truncated ? { next_action: "The source list reached its limit and may be incomplete. Inspect a known connection_id with sources status; do not infer that an unlisted source is absent." } : {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (input.action === "connect") {
|
|
252
|
+
if (!SOURCE_KINDS.has(input.kind)) {
|
|
253
|
+
throw new EmberOperatorError("unsupported_source", `Unsupported source kind: ${input.kind}`);
|
|
254
|
+
}
|
|
255
|
+
const data = await this.client.request(`/v1/ember/workspaces/${encodeURIComponent(this.workspaceId)}/operator/source-handoffs`, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
body: { kind: input.kind, name: input.name, mode: input.mode, return_to: input.return_to },
|
|
258
|
+
});
|
|
259
|
+
return { ok: true, action: "sources.connect", data: bounded(data), next_action: "Open authorization_url to finish connecting the source." };
|
|
260
|
+
}
|
|
261
|
+
const lookup = input;
|
|
262
|
+
const connectors = await this.client.listConnectors(this.workspaceId, MAX_CONNECTOR_LOOKUP_ITEMS, { connectorUsage: "warehouse_ingestion" });
|
|
263
|
+
const connector = connectors.find((item) => item.id === lookup.connection_id);
|
|
264
|
+
if (!connector && connectors.length >= MAX_CONNECTOR_LOOKUP_ITEMS) {
|
|
265
|
+
throw new EmberOperatorError("source_lookup_incomplete", "The bounded source list is full, so this lookup cannot establish whether the connection exists. Inspect the source in Ember; do not create a duplicate connection.");
|
|
266
|
+
}
|
|
267
|
+
if (!connector)
|
|
268
|
+
throw new EmberOperatorError("connection_not_found", "Source connection was not found in this workspace.");
|
|
269
|
+
if (input.action === "status")
|
|
270
|
+
return { ok: true, action: "sources.status", data: withHandle(connector, "connection_id", connector.id) };
|
|
271
|
+
const verification = await this.client.request(`/v1/connectors/${encodeURIComponent(connector.kind)}/verify`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
body: { workspace_id: this.workspaceId, connector_id: connector.id },
|
|
274
|
+
});
|
|
275
|
+
return { ok: true, action: "sources.verify", data: withHandle(verification, "connection_id", connector.id) };
|
|
276
|
+
}
|
|
277
|
+
async ingestion(input) {
|
|
278
|
+
if (input.action === "inspect_table" || input.action === "edit_table") {
|
|
279
|
+
return { ok: true, action: `ingestion.${input.action}`, data: bounded(await editPluginIngestion(this.client, this.workspaceId, input)) };
|
|
280
|
+
}
|
|
281
|
+
if (input.action === "status") {
|
|
282
|
+
const data = input.ingestion_run_id
|
|
283
|
+
? await this.client.getWarehouseIngestionConnection(this.workspaceId, input.ingestion_run_id)
|
|
284
|
+
: await this.listIngestionSummaries();
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
action: "ingestion.status",
|
|
288
|
+
data: Array.isArray(data)
|
|
289
|
+
? bounded({ items: data.map(summarizeIngestion) })
|
|
290
|
+
: withHandle(summarizeIngestion(data), "ingestion_run_id", input.ingestion_run_id),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (input.action === "sync" || input.action === "pause" || input.action === "resume") {
|
|
294
|
+
const suffix = input.action;
|
|
295
|
+
const data = await this.client.request(`/v1/workspaces/${encodeURIComponent(this.workspaceId)}/warehouse/ingestion-connections/${encodeURIComponent(input.ingestion_run_id)}/${suffix}`, { method: "POST" });
|
|
296
|
+
return { ok: true, action: `ingestion.${input.action}`, data: withHandle(data, "ingestion_run_id", input.ingestion_run_id) };
|
|
297
|
+
}
|
|
298
|
+
const startInput = input;
|
|
299
|
+
if (startInput.tables && startInput.tables.length === 0)
|
|
300
|
+
throw new EmberOperatorError("no_selected_tables", "An explicit empty table list selects no tables. Omit tables to connect all eligible tables, or provide the intended selection.");
|
|
301
|
+
const databaseId = startInput.database_id ?? (await this.defaultDatabaseId());
|
|
302
|
+
const engine = startInput.ingestion_engine ?? "worker_batch";
|
|
303
|
+
const allTables = startInput.tables === undefined;
|
|
304
|
+
const selectedTables = (startInput.tables ?? []).map((table) => ({
|
|
305
|
+
...table,
|
|
306
|
+
primary_key_columns: table.primary_key_columns ?? [],
|
|
307
|
+
selected_columns: table.selected_columns ?? [],
|
|
308
|
+
incremental_strategy: table.incremental_strategy ?? (engine === "worker_batch" ? "cursor" : "cdc"),
|
|
309
|
+
}));
|
|
310
|
+
const body = {
|
|
311
|
+
connector_id: startInput.connection_id,
|
|
312
|
+
warehouse_database_id: databaseId,
|
|
313
|
+
source_kind: startInput.source_kind,
|
|
314
|
+
ingestion_engine: engine,
|
|
315
|
+
schedule_interval_minutes: startInput.cadence_minutes,
|
|
316
|
+
tables: selectedTables,
|
|
317
|
+
...(startInput.action === "start" ? { name: startInput.name ?? `${startInput.source_kind} ingestion` } : {}),
|
|
318
|
+
};
|
|
319
|
+
const plan = await this.client.preflightWarehouseIngestionConnection(this.workspaceId, body);
|
|
320
|
+
const autoAddNewTables = allTables && engine === "worker_batch";
|
|
321
|
+
if (startInput.action === "plan")
|
|
322
|
+
return { ok: true, action: "ingestion.plan", data: bounded({ ...plan, selection_mode: allTables ? "all_eligible" : "explicit", auto_add_new_tables: autoAddNewTables }) };
|
|
323
|
+
if (allTables && typeof plan.summary?.total === "number" && plan.summary.total !== plan.tables?.length) {
|
|
324
|
+
throw new EmberOperatorError("incomplete_table_inventory", "The preflight table inventory is incomplete. Inspect ingestion plan before starting; no tables were connected.");
|
|
325
|
+
}
|
|
326
|
+
const plannedTables = selectedTables.length
|
|
327
|
+
? selectedTables
|
|
328
|
+
: (plan.tables ?? [])
|
|
329
|
+
.filter((table) => !table.blocking_reasons?.length && table.recommended_selection)
|
|
330
|
+
.map((table) => table.recommended_selection);
|
|
331
|
+
if (!plannedTables.length)
|
|
332
|
+
throw new EmberOperatorError("no_eligible_tables", "No eligible tables passed ingestion preflight.");
|
|
333
|
+
const data = await this.client.createWarehouseIngestionConnection(this.workspaceId, { ...body, tables: plannedTables, auto_add_new_tables: autoAddNewTables });
|
|
334
|
+
const skipped = allTables ? Math.max(0, (plan.tables?.length ?? 0) - plannedTables.length) : 0;
|
|
335
|
+
return {
|
|
336
|
+
ok: true, action: `ingestion.${startInput.action}`,
|
|
337
|
+
data: withHandle({ ...data, selection_mode: allTables ? "all_eligible" : "explicit", selected_table_count: plannedTables.length, blocked_table_count: skipped }, "ingestion_run_id"),
|
|
338
|
+
next_action: skipped
|
|
339
|
+
? `${skipped} discovered tables need attention and were not connected. Inspect ingestion plan for exact blocking reasons; keep working with the connected tables and report this coverage gap.`
|
|
340
|
+
: "Inspect ingestion status until tables are synced, then use catalog and read-only queries to produce the first answer with evidence.",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
async query(input) {
|
|
344
|
+
if (input.action === "run") {
|
|
345
|
+
if (input.max_result_rows !== undefined && input.max_result_rows !== 10000) {
|
|
346
|
+
throw new EmberOperatorError("invalid_result_row_cap", "The retained-result cap must be 10000. Use SQL LIMIT for fewer rows or query.results limit for smaller pages.");
|
|
347
|
+
}
|
|
348
|
+
const data = await this.client.createWarehouseReadonlyQuery(this.workspaceId, {
|
|
349
|
+
sql: input.sql,
|
|
350
|
+
default_database: input.database ?? "ember",
|
|
351
|
+
max_result_rows: input.max_result_rows,
|
|
352
|
+
});
|
|
353
|
+
const parsed = queryResponseSchema.safeParse(data);
|
|
354
|
+
if (!parsed.success)
|
|
355
|
+
invalidQueryResponse(input.action);
|
|
356
|
+
return { ok: true, action: "query.run", data: withHandle({ ...parsed.data, query_id: parsed.data.id }, "query_id") };
|
|
357
|
+
}
|
|
358
|
+
if (input.action === "history") {
|
|
359
|
+
const data = await this.client.listWarehouseQueries(this.workspaceId, { limit: Math.min(input.limit ?? 25, 100) });
|
|
360
|
+
const parsed = queryHistoryResponseSchema.safeParse(data);
|
|
361
|
+
if (!parsed.success)
|
|
362
|
+
invalidQueryResponse(input.action);
|
|
363
|
+
return { ok: true, action: "query.history", data: bounded({ items: parsed.data }) };
|
|
364
|
+
}
|
|
365
|
+
if (input.action === "results") {
|
|
366
|
+
const data = await this.client.getWarehouseQueryResults(this.workspaceId, input.query_id, {
|
|
367
|
+
maxResults: Math.min(input.limit ?? 100, 100),
|
|
368
|
+
nextToken: input.next_token,
|
|
369
|
+
});
|
|
370
|
+
const parsed = queryResultsResponseSchema.safeParse(data);
|
|
371
|
+
if (!parsed.success || parsed.data.query.id !== input.query_id)
|
|
372
|
+
invalidQueryResponse(input.action);
|
|
373
|
+
const page = bounded(parsed.data);
|
|
374
|
+
if (page._truncated) {
|
|
375
|
+
throw new EmberOperatorError("result_page_too_large", "This result page exceeds the response budget. Retry the SAME query_id and next_token with a smaller limit. If one row is still too large, select fewer columns or shorter values. No rows or advanced cursor were returned.");
|
|
376
|
+
}
|
|
377
|
+
return { ok: true, action: "query.results", data: page };
|
|
378
|
+
}
|
|
379
|
+
const data = input.action === "cancel"
|
|
380
|
+
? await this.client.cancelWarehouseQuery(this.workspaceId, input.query_id)
|
|
381
|
+
: await this.client.getWarehouseQuery(this.workspaceId, input.query_id);
|
|
382
|
+
const parsed = queryResponseSchema.safeParse(data);
|
|
383
|
+
if (!parsed.success || parsed.data.id !== input.query_id)
|
|
384
|
+
invalidQueryResponse(input.action);
|
|
385
|
+
return { ok: true, action: `query.${input.action}`, data: withHandle({ ...parsed.data, query_id: parsed.data.id }, "query_id") };
|
|
386
|
+
}
|
|
387
|
+
async warehouse(input) {
|
|
388
|
+
if (input.action === "usage") {
|
|
389
|
+
const data = await this.client.request("/v1/workspace-usage", {
|
|
390
|
+
query: { workspace_id: this.workspaceId, window: input.window ?? "month_to_date" },
|
|
391
|
+
});
|
|
392
|
+
return { ok: true, action: "warehouse.usage", data: bounded(data) };
|
|
393
|
+
}
|
|
394
|
+
const result = input.action === "status" ? await this.status() : await this.setup(input);
|
|
395
|
+
return { ...result, action: `warehouse.${input.action}` };
|
|
396
|
+
}
|
|
397
|
+
async catalog(input) {
|
|
398
|
+
if (isDeletionAction(input)) {
|
|
399
|
+
const data = bounded(await operatePluginDeletion(this.client, this.workspaceId, input));
|
|
400
|
+
if (data._truncated)
|
|
401
|
+
throw new EmberOperatorError("deletion_response_too_large", "Open Governance deletion history for the complete request; do not infer completion from a partial result.");
|
|
402
|
+
return { ok: true, action: `catalog.${input.action}`, data, next_action: ["preview_deletion", "preview_automatic_request"].includes(input.action) ? "Review the affected counts and scope with the user. Execute this exact request only after explicit confirmation." : ["automatic_requests", "configure_automatic_requests", "check_automatic_requests"].includes(input.action) ? "Automatic requests only collect consent withdrawals and expiry. Review a pending request with preview_automatic_request; deletion still requires a fresh preview and explicit confirmation." : "Read deletion_history for request outcomes. Unconfigured copies and re-ingestion are not covered." };
|
|
403
|
+
}
|
|
404
|
+
if (input.action === "save_requirements" || input.action === "policy_context" || input.action === "record_assessment") {
|
|
405
|
+
const data = bounded(await operatePluginPolicy(this.client, this.workspaceId, input));
|
|
406
|
+
if (data._truncated)
|
|
407
|
+
throw new EmberOperatorError("policy_context_too_large", "The policy context exceeds the response budget. Open its Embrasure policy page for complete requirements and evidence; do not treat a partial response as complete coverage.");
|
|
408
|
+
return { ok: true, action: `catalog.${input.action}`, data,
|
|
409
|
+
next_action: input.action === "policy_context"
|
|
410
|
+
? "Use the pinned revision and source-backed requirements in your existing coding agent. Resolve open questions and coverage gaps before implementing. Report evidence against this revision; this context is not a compliance certification."
|
|
411
|
+
: input.preview !== false
|
|
412
|
+
? "Review the proposed source or assessment, then apply the requested change with preview=false."
|
|
413
|
+
: "Read policy_context to confirm the saved state. External assessment results are reported evidence, not independent verification." };
|
|
414
|
+
}
|
|
415
|
+
if (input.action === "save" || input.action === "correct") {
|
|
416
|
+
return { ok: true, action: `catalog.${input.action}`, data: bounded(await editPluginCatalog(this.client, this.workspaceId, input)) };
|
|
417
|
+
}
|
|
418
|
+
const result = await readCatalog(this.client, this.workspaceId, input);
|
|
419
|
+
const data = bounded(result.data);
|
|
420
|
+
if (data._truncated && input.action !== "search") {
|
|
421
|
+
throw new EmberOperatorError("catalog_page_too_large", "This catalog page exceeds the response budget. Retry the SAME cursor or column_offset with a smaller limit/column_limit. For context describe, set include_related=false. No partial definition or advanced cursor was returned.");
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
...result, data,
|
|
425
|
+
...(data._truncated ? { next_action: "The search response exceeded its budget. Narrow the terms or reduce limit; retrieve a complete definition with catalog describe before SQL." } : {}),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async defaultDatabaseId() {
|
|
429
|
+
const databases = await this.client.listWarehouseDatabases(this.workspaceId);
|
|
430
|
+
const database = databases.find((item) => item.name === "ember") ?? databases[0];
|
|
431
|
+
if (!database)
|
|
432
|
+
throw new EmberOperatorError("setup_required", "Run ember setup before configuring ingestion.");
|
|
433
|
+
return database.id;
|
|
434
|
+
}
|
|
435
|
+
async listIngestionSummaries() {
|
|
436
|
+
return this.client.request(`/v1/workspaces/${encodeURIComponent(this.workspaceId)}/warehouse/ingestion-connections`, { query: { details: false } });
|
|
437
|
+
}
|
|
438
|
+
async recordToolEvent(request, result, outcome, durationMs) {
|
|
439
|
+
const handles = request.tool === "catalog" && isDeletionAction(request.input)
|
|
440
|
+
? resourceHandles({ request_id: result?.data.request_id })
|
|
441
|
+
: resourceHandles(result?.data);
|
|
442
|
+
const cost = result?.data.cost_usd;
|
|
443
|
+
await this.client.recordEmberOperatorToolEvent(this.workspaceId, {
|
|
444
|
+
tool: request.tool,
|
|
445
|
+
action: result?.action ?? `${request.tool}.${String(request.input.action ?? "run")}`,
|
|
446
|
+
argument_hash: createHash("sha256").update(stableJson(request.input)).digest("hex"),
|
|
447
|
+
outcome,
|
|
448
|
+
duration_ms: Math.min(Math.max(durationMs, 0), 300_000),
|
|
449
|
+
resource_handles: handles,
|
|
450
|
+
cost_usd: typeof cost === "number" && Number.isFinite(cost) && cost >= 0 ? cost : undefined,
|
|
451
|
+
}).catch(() => undefined);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function stableJson(value) {
|
|
455
|
+
if (Array.isArray(value))
|
|
456
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
457
|
+
if (value && typeof value === "object") {
|
|
458
|
+
return `{${Object.entries(value)
|
|
459
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
460
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`).join(",")}}`;
|
|
461
|
+
}
|
|
462
|
+
return JSON.stringify(value) ?? "null";
|
|
463
|
+
}
|
|
464
|
+
function resourceHandles(value) {
|
|
465
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
466
|
+
return {};
|
|
467
|
+
const handles = {};
|
|
468
|
+
for (const [key, child] of Object.entries(value)) {
|
|
469
|
+
if (Object.keys(handles).length >= 10)
|
|
470
|
+
break;
|
|
471
|
+
if (key.endsWith("_id") && typeof child === "string" && child)
|
|
472
|
+
handles[key.slice(0, 100)] = child.slice(0, 200);
|
|
473
|
+
}
|
|
474
|
+
return handles;
|
|
475
|
+
}
|
|
476
|
+
export function stableError(error) {
|
|
477
|
+
if (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name)) {
|
|
478
|
+
return {
|
|
479
|
+
ok: false, action: "error",
|
|
480
|
+
data: { code: error.name === "TimeoutError" ? "operation_timed_out" : "operation_cancelled", retryable: false,
|
|
481
|
+
message: "The request stopped before its outcome was confirmed. Check warehouse, ingestion, or query status/history before retrying a change; disconnecting does not undo work already accepted by the API." },
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const known = error instanceof EmberOperatorError;
|
|
485
|
+
const apiError = (error && typeof error === "object" ? error : {});
|
|
486
|
+
const code = known ? error.code : apiError.code ?? (apiError.status === 401 ? "authentication_required" : "operation_failed");
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
action: "error",
|
|
490
|
+
data: bounded({ code, message: error instanceof Error ? error.message : "The Ember operation failed.", retryable: known ? error.retryable : false }),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
export { bounded as boundOperatorOutput, asJson };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { EmbrasureApiClient } from "@embrasure/api-client";
|
|
2
|
+
import type { PluginDeletionInput } from "./types.js";
|
|
3
|
+
export declare function isDeletionAction(input: {
|
|
4
|
+
action: string;
|
|
5
|
+
}): input is PluginDeletionInput;
|
|
6
|
+
/** Uses the same scoped preview/confirmation API as Governance, never caller-provided SQL. */
|
|
7
|
+
export declare function operatePluginDeletion(client: EmbrasureApiClient, workspaceId: string, input: PluginDeletionInput): Promise<import("@embrasure/api-client").AutomaticRequestsState | import("@embrasure/api-client").DeletionConfiguration | {
|
|
8
|
+
requests: import("@embrasure/api-client").DeletionRequest[];
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
request_id: string;
|
|
12
|
+
id: string;
|
|
13
|
+
status: "preview" | "running" | "completed" | "failed" | "unknown";
|
|
14
|
+
subject_id: string;
|
|
15
|
+
configuration_revision: string;
|
|
16
|
+
created_at: string;
|
|
17
|
+
expires_at: string;
|
|
18
|
+
requested_by: string;
|
|
19
|
+
executed_by?: string;
|
|
20
|
+
started_at?: string;
|
|
21
|
+
completed_at?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
targets: Array<import("@embrasure/api-client").DeletionTarget & {
|
|
24
|
+
matched_rows: number;
|
|
25
|
+
deleted_rows?: number;
|
|
26
|
+
remaining_rows?: number;
|
|
27
|
+
query_id?: string | null;
|
|
28
|
+
}>;
|
|
29
|
+
}>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const actions = new Set(["deletion_config", "configure_deletion", "preview_deletion", "execute_deletion", "deletion_history", "automatic_requests", "configure_automatic_requests", "check_automatic_requests", "preview_automatic_request"]);
|
|
2
|
+
export function isDeletionAction(input) {
|
|
3
|
+
return actions.has(input.action);
|
|
4
|
+
}
|
|
5
|
+
/** Uses the same scoped preview/confirmation API as Governance, never caller-provided SQL. */
|
|
6
|
+
export async function operatePluginDeletion(client, workspaceId, input) {
|
|
7
|
+
switch (input.action) {
|
|
8
|
+
case "automatic_requests": return client.getAutomaticRequests(workspaceId);
|
|
9
|
+
case "configure_automatic_requests": {
|
|
10
|
+
const { action: _action, ...configuration } = input;
|
|
11
|
+
return client.saveAutomaticRequests(workspaceId, configuration);
|
|
12
|
+
}
|
|
13
|
+
case "check_automatic_requests": return client.checkAutomaticRequests(workspaceId);
|
|
14
|
+
case "preview_automatic_request": {
|
|
15
|
+
const data = await client.previewAutomaticRequest(workspaceId, input.request_id);
|
|
16
|
+
return { ...data, request_id: data.id };
|
|
17
|
+
}
|
|
18
|
+
case "deletion_config": return client.getDeletionConfiguration(workspaceId);
|
|
19
|
+
case "deletion_history": return client.listDeletionRequests(workspaceId);
|
|
20
|
+
case "configure_deletion": return client.saveDeletionConfiguration(workspaceId, { targets: input.targets, expected_revision: input.expected_revision });
|
|
21
|
+
case "preview_deletion": {
|
|
22
|
+
const data = await client.previewDeletionRequest(workspaceId, { subject_id: input.subject_id, configuration_revision: input.configuration_revision });
|
|
23
|
+
return { ...data, request_id: data.id };
|
|
24
|
+
}
|
|
25
|
+
case "execute_deletion": {
|
|
26
|
+
const data = await client.executeDeletionRequest(workspaceId, input.request_id, { confirmation: input.confirmation });
|
|
27
|
+
return { ...data, request_id: data.id };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|