@dbx-tools/appkit 0.9.118 → 0.9.120
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 +27 -21
- package/index.ts +6 -1
- package/lib/index.d.ts +6 -1
- package/lib/index.js +5 -2
- package/lib/src/appkit.js +17 -20
- package/lib/src/{_cache-storage.d.ts → cache-storage.d.ts} +28 -16
- package/lib/src/cache-storage.js +276 -0
- package/lib/src/files-cache.d.ts +2 -4
- package/lib/src/files-cache.js +39 -38
- package/lib/src/identity.d.ts +12 -0
- package/lib/src/identity.js +69 -3
- package/lib/src/interceptor.js +6 -3
- package/lib/src/lakebase-resolver.d.ts +14 -0
- package/lib/src/lakebase-resolver.js +22 -3
- package/lib/src/migration.js +6 -7
- package/lib/src/plugin-execution.js +3 -3
- package/lib/src/provision.js +2 -2
- package/lib/src/tool-operations.d.ts +33 -0
- package/lib/src/tool-operations.js +43 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -9
- package/src/appkit.ts +16 -21
- package/src/{_cache-storage.ts → cache-storage.ts} +138 -71
- package/src/files-cache.ts +38 -44
- package/src/identity.ts +67 -2
- package/src/interceptor.ts +5 -2
- package/src/lakebase-resolver.ts +23 -1
- package/src/migration.ts +3 -5
- package/src/plugin-execution.ts +2 -2
- package/src/provision.ts +1 -1
- package/src/tool-operations.ts +80 -0
- package/lib/src/_cache-storage.js +0 -235
package/src/files-cache.ts
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AppKit-global filesystem cache with user-isolated process-local
|
|
2
|
+
* AppKit-global filesystem cache with user-isolated process-local values.
|
|
3
3
|
*
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
|
|
8
|
+
import { cacheKeys, closeCache, createCache, getOrSet } from "@dbx-tools/cache/cache";
|
|
8
9
|
import { errorUtils, log, object } from "@dbx-tools/shared-core";
|
|
9
10
|
import { posixPath, type CacheValue, type FileSystemCache } from "@dbx-tools/shared-fs";
|
|
10
|
-
import { LRUCache } from "lru-cache";
|
|
11
11
|
|
|
12
12
|
const DEFAULT_TTL_MS = 60_000;
|
|
13
13
|
const DEFAULT_INVALIDATION_BATCH_MS = 25;
|
|
14
14
|
const DEFAULT_MAX_ENTRIES = 1_000;
|
|
15
|
-
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
16
15
|
const DEFAULT_MAX_SCOPES = 256;
|
|
17
16
|
const logger = log.logger("files-cache");
|
|
18
17
|
|
|
19
|
-
interface RetainedEntry {
|
|
20
|
-
value: CacheValue;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
interface ScopeState {
|
|
24
19
|
cache: FileSystemCache;
|
|
25
|
-
entries:
|
|
20
|
+
entries: ReturnType<typeof createCache>;
|
|
26
21
|
group: GroupState;
|
|
27
22
|
scope: ResolvedFilesCacheScope;
|
|
28
23
|
sources: Map<string, Promise<object>>;
|
|
@@ -36,7 +31,6 @@ interface GroupState {
|
|
|
36
31
|
|
|
37
32
|
interface ResolvedFilesCacheConfig {
|
|
38
33
|
invalidationBatchMs: number;
|
|
39
|
-
maxBytes: number;
|
|
40
34
|
maxEntries: number;
|
|
41
35
|
maxScopes: number;
|
|
42
36
|
ttlMs: number;
|
|
@@ -57,8 +51,6 @@ export interface FilesCachePluginConfig extends BasePluginConfig {
|
|
|
57
51
|
invalidationBatchMs?: number;
|
|
58
52
|
/** Maximum entries retained in each user L1. Defaults to 1,000. */
|
|
59
53
|
maxEntries?: number;
|
|
60
|
-
/** Maximum retained bytes in each user L1. Defaults to 64 MiB. */
|
|
61
|
-
maxBytes?: number;
|
|
62
54
|
/** Maximum active user scopes retained by the process. Defaults to 256. */
|
|
63
55
|
maxScopes?: number;
|
|
64
56
|
}
|
|
@@ -101,7 +93,7 @@ export interface FilesCacheExports {
|
|
|
101
93
|
export class FilesCacheManager {
|
|
102
94
|
private readonly config: ResolvedFilesCacheConfig;
|
|
103
95
|
private readonly groups = new Map<string, GroupState>();
|
|
104
|
-
private readonly scopes:
|
|
96
|
+
private readonly scopes: ReturnType<typeof createCache>;
|
|
105
97
|
|
|
106
98
|
constructor(config: FilesCachePluginConfig = {}) {
|
|
107
99
|
this.config = {
|
|
@@ -112,15 +104,9 @@ export class FilesCacheManager {
|
|
|
112
104
|
"invalidationBatchMs",
|
|
113
105
|
),
|
|
114
106
|
maxEntries: positiveInteger(config.maxEntries, DEFAULT_MAX_ENTRIES, "maxEntries"),
|
|
115
|
-
maxBytes: positiveInteger(config.maxBytes, DEFAULT_MAX_BYTES, "maxBytes"),
|
|
116
107
|
maxScopes: positiveInteger(config.maxScopes, DEFAULT_MAX_SCOPES, "maxScopes"),
|
|
117
108
|
};
|
|
118
|
-
this.scopes =
|
|
119
|
-
max: this.config.maxScopes,
|
|
120
|
-
dispose: (state) => {
|
|
121
|
-
state.group.scopes.delete(state);
|
|
122
|
-
},
|
|
123
|
-
});
|
|
109
|
+
this.scopes = createCache({ maxEntries: this.config.maxScopes, useClone: false });
|
|
124
110
|
}
|
|
125
111
|
|
|
126
112
|
/** Return one user-isolated cache within a host/workspace invalidation group. */
|
|
@@ -158,12 +144,23 @@ export class FilesCacheManager {
|
|
|
158
144
|
userKey: scope.userKey,
|
|
159
145
|
workspaceId: scope.workspaceId,
|
|
160
146
|
});
|
|
161
|
-
let state = this.scopes.get(scopeKey);
|
|
147
|
+
let state = await this.scopes.get<ScopeState>(scopeKey);
|
|
162
148
|
if (!state) {
|
|
149
|
+
if ([...cacheKeys(this.scopes)].length >= this.config.maxScopes) {
|
|
150
|
+
const oldestKey = cacheKeys(this.scopes).next().value;
|
|
151
|
+
if (oldestKey) {
|
|
152
|
+
const oldest = await this.scopes.get<ScopeState>(oldestKey);
|
|
153
|
+
if (oldest) {
|
|
154
|
+
oldest.group.scopes.delete(oldest);
|
|
155
|
+
await closeCache(oldest.entries);
|
|
156
|
+
}
|
|
157
|
+
await this.scopes.delete(oldestKey);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
163
160
|
const group = this.group(scope);
|
|
164
161
|
state = this.createScope(scope, group);
|
|
165
162
|
group.scopes.add(state);
|
|
166
|
-
this.scopes.set(scopeKey, state);
|
|
163
|
+
await this.scopes.set(scopeKey, state);
|
|
167
164
|
}
|
|
168
165
|
return state;
|
|
169
166
|
}
|
|
@@ -180,7 +177,11 @@ export class FilesCacheManager {
|
|
|
180
177
|
group.timer = undefined;
|
|
181
178
|
}
|
|
182
179
|
await this.flush();
|
|
183
|
-
this.scopes
|
|
180
|
+
for (const key of cacheKeys(this.scopes)) {
|
|
181
|
+
const state = await this.scopes.get<ScopeState>(key);
|
|
182
|
+
if (state) await closeCache(state.entries);
|
|
183
|
+
}
|
|
184
|
+
await closeCache(this.scopes);
|
|
184
185
|
this.groups.clear();
|
|
185
186
|
}
|
|
186
187
|
|
|
@@ -203,11 +204,7 @@ export class FilesCacheManager {
|
|
|
203
204
|
const state = {} as ScopeState;
|
|
204
205
|
state.scope = scope;
|
|
205
206
|
state.group = group;
|
|
206
|
-
state.entries =
|
|
207
|
-
max: this.config.maxEntries,
|
|
208
|
-
maxSize: this.config.maxBytes,
|
|
209
|
-
sizeCalculation: (entry, cacheKey) => retainedSize(cacheKey, entry),
|
|
210
|
-
});
|
|
207
|
+
state.entries = createCache({ maxEntries: this.config.maxEntries, useClone: false });
|
|
211
208
|
state.sources = new Map();
|
|
212
209
|
state.cache = {
|
|
213
210
|
read: <T extends CacheValue>(cacheKey: string, load: () => T | Promise<T>) =>
|
|
@@ -224,16 +221,21 @@ export class FilesCacheManager {
|
|
|
224
221
|
load: () => T | Promise<T>,
|
|
225
222
|
): Promise<T> {
|
|
226
223
|
if (!state.group.pending.has(key)) {
|
|
227
|
-
const retained = state.entries.get(key);
|
|
228
|
-
if (retained) return retained
|
|
224
|
+
const retained = await state.entries.get<T>(key);
|
|
225
|
+
if (retained !== undefined) return retained;
|
|
229
226
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
227
|
+
if (state.group.pending.has(key)) {
|
|
228
|
+
const value = await load();
|
|
229
|
+
await state.entries.set(key, value, { ttl: state.scope.ttlMs });
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
return (await getOrSet(state.entries, key, () => Promise.resolve(load()), {
|
|
233
|
+
ttl: state.scope.ttlMs,
|
|
234
|
+
})) as T;
|
|
233
235
|
}
|
|
234
236
|
|
|
235
237
|
private *keys(state: ScopeState): Iterable<string> {
|
|
236
|
-
for (const key of state.entries
|
|
238
|
+
for (const key of cacheKeys(state.entries)) {
|
|
237
239
|
if (!state.group.pending.has(key)) yield key;
|
|
238
240
|
}
|
|
239
241
|
}
|
|
@@ -245,7 +247,7 @@ export class FilesCacheManager {
|
|
|
245
247
|
state.group.timer = undefined;
|
|
246
248
|
void this.flushInvalidations(state.group).catch((error) => {
|
|
247
249
|
logger.warn("filesystem cache invalidation batch failed", {
|
|
248
|
-
error: errorUtils.
|
|
250
|
+
error: errorUtils.toError(error),
|
|
249
251
|
});
|
|
250
252
|
});
|
|
251
253
|
}, this.config.invalidationBatchMs);
|
|
@@ -259,12 +261,12 @@ export class FilesCacheManager {
|
|
|
259
261
|
const keys = [...group.pending];
|
|
260
262
|
group.pending.clear();
|
|
261
263
|
for (const state of group.scopes) {
|
|
262
|
-
|
|
264
|
+
await Promise.all(keys.map((key) => state.entries.delete(key)));
|
|
263
265
|
}
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
|
|
267
|
-
/** AppKit plugin that owns process-local filesystem
|
|
269
|
+
/** AppKit plugin that owns process-local filesystem caches. */
|
|
268
270
|
export class FilesCachePlugin extends Plugin<FilesCachePluginConfig> {
|
|
269
271
|
static manifest = {
|
|
270
272
|
name: "files-cache",
|
|
@@ -324,11 +326,3 @@ function positiveDuration(value: number | undefined, fallback: number, name: str
|
|
|
324
326
|
function positiveInteger(value: number | undefined, fallback: number, name: string): number {
|
|
325
327
|
return Math.floor(positiveDuration(value, fallback, name));
|
|
326
328
|
}
|
|
327
|
-
|
|
328
|
-
function retainedSize(key: string, entry: RetainedEntry): number {
|
|
329
|
-
try {
|
|
330
|
-
return Buffer.byteLength(key) + Buffer.byteLength(JSON.stringify(entry.value));
|
|
331
|
-
} catch {
|
|
332
|
-
return Number.MAX_SAFE_INTEGER;
|
|
333
|
-
}
|
|
334
|
-
}
|
package/src/identity.ts
CHANGED
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
* @module
|
|
53
53
|
*/
|
|
54
54
|
|
|
55
|
-
import { ConfigurationError } from "@databricks/appkit";
|
|
55
|
+
import { ConfigurationError, Plugin } from "@databricks/appkit";
|
|
56
56
|
import { configUtils } from "@dbx-tools/core";
|
|
57
|
-
import { stringUtils, token } from "@dbx-tools/shared-core";
|
|
57
|
+
import { log, stringUtils, token } from "@dbx-tools/shared-core";
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Header the Databricks Apps front door forwards the caller's OBO token on.
|
|
@@ -158,3 +158,68 @@ export function useServicePrincipal(mode: IdentityMode, req?: HeaderBearing): bo
|
|
|
158
158
|
if (mode === "user") return false;
|
|
159
159
|
return requestAccessToken(req) === undefined;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
const logger = log.logger("appkit");
|
|
163
|
+
const skippedAsUser = new Set<string>();
|
|
164
|
+
const AS_USER_SKIP_WARN = "Skipping user impersonation";
|
|
165
|
+
const AS_USER_PATCHED = Symbol.for("dbx-tools.appkit.asUserDevLog");
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* In development, keep AppKit `asUser` behavior. The first missing-token skip
|
|
169
|
+
* for a forwarded email or user id uses AppKit's own warn; later skips for
|
|
170
|
+
* that user, and every skip with no forwarded identity, are silent. Request
|
|
171
|
+
* objects are not a stable key: each plugin call typically has its own `req`.
|
|
172
|
+
*
|
|
173
|
+
* No-op outside development, when `Plugin.asUser` is missing or not a
|
|
174
|
+
* function, or if the prototype cannot be rewritten. Failures never throw:
|
|
175
|
+
* AppKit keeps its original `asUser`. Extra arguments are forwarded so a
|
|
176
|
+
* later signature change still reaches the original method.
|
|
177
|
+
*/
|
|
178
|
+
export function installAsUserDevelopmentPatch(): void {
|
|
179
|
+
if (process.env.NODE_ENV !== "development") return;
|
|
180
|
+
try {
|
|
181
|
+
const proto = Plugin.prototype as unknown as Record<PropertyKey, unknown>;
|
|
182
|
+
if (proto[AS_USER_PATCHED] === true) return;
|
|
183
|
+
const original = proto.asUser;
|
|
184
|
+
if (typeof original !== "function") return;
|
|
185
|
+
proto[AS_USER_PATCHED] = true;
|
|
186
|
+
proto.asUser = function patchedAsUser(this: unknown, req: unknown, ...rest: unknown[]) {
|
|
187
|
+
const headers = asHeaderBearing(req);
|
|
188
|
+
const args = [req, ...rest];
|
|
189
|
+
if (!headers || requestAccessToken(headers) !== undefined) {
|
|
190
|
+
return Function.prototype.apply.call(original, this, args);
|
|
191
|
+
}
|
|
192
|
+
return withAsUserSkipWarnSuppressed(headers, () =>
|
|
193
|
+
Function.prototype.apply.call(original, this, args),
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.debug("asUser development patch skipped", { error: String(error) });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function asHeaderBearing(req: unknown): HeaderBearing | undefined {
|
|
202
|
+
if (req === null || typeof req !== "object") return undefined;
|
|
203
|
+
if (typeof (req as HeaderBearing).header !== "function") return undefined;
|
|
204
|
+
return req as HeaderBearing;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function withAsUserSkipWarnSuppressed<T>(req: HeaderBearing, fn: () => T): T {
|
|
208
|
+
const user = requestUserEmail(req) ?? requestUserId(req);
|
|
209
|
+
if (user && !skippedAsUser.has(user)) {
|
|
210
|
+
skippedAsUser.add(user);
|
|
211
|
+
return fn();
|
|
212
|
+
}
|
|
213
|
+
const warn = console.warn;
|
|
214
|
+
console.warn = (...args: unknown[]) => {
|
|
215
|
+
if (!log.isLevelEnabled("debug") && args.some((value) => typeof value === "string" && value.includes(AS_USER_SKIP_WARN))) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
warn.apply(console, args as Parameters<typeof console.warn>);
|
|
219
|
+
};
|
|
220
|
+
try {
|
|
221
|
+
return fn();
|
|
222
|
+
} finally {
|
|
223
|
+
console.warn = warn;
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/interceptor.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@databricks/appkit";
|
|
23
|
-
import { log } from "@dbx-tools/shared-core";
|
|
23
|
+
import { errorUtils, log } from "@dbx-tools/shared-core";
|
|
24
24
|
import type { LakebaseConnection } from "./lakebase-resolver.ts";
|
|
25
25
|
|
|
26
26
|
const logger = log.logger("interceptor");
|
|
@@ -120,7 +120,10 @@ export function createInterceptorContext(env: ResolvedAppEnv): InterceptorRuntim
|
|
|
120
120
|
} catch (error) {
|
|
121
121
|
// Match AppKit: a failing lifecycle handler is logged, never fatal, and
|
|
122
122
|
// never blocks its siblings.
|
|
123
|
-
logger.warn("lifecycle handler failed", {
|
|
123
|
+
logger.warn("lifecycle handler failed", {
|
|
124
|
+
event,
|
|
125
|
+
error: errorUtils.toError(error),
|
|
126
|
+
});
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
};
|
package/src/lakebase-resolver.ts
CHANGED
|
@@ -40,11 +40,13 @@ import {
|
|
|
40
40
|
ExecutionError,
|
|
41
41
|
getUsernameWithApiLookup,
|
|
42
42
|
getWorkspaceClient,
|
|
43
|
+
type LakebasePool,
|
|
43
44
|
ValidationError,
|
|
44
45
|
} from "@databricks/appkit";
|
|
45
46
|
import { configUtils, projectUtils } from "@dbx-tools/core";
|
|
46
47
|
import {
|
|
47
48
|
asyncUtils,
|
|
49
|
+
errorUtils,
|
|
48
50
|
log,
|
|
49
51
|
object,
|
|
50
52
|
options as sharedOptions,
|
|
@@ -113,6 +115,23 @@ export interface LakebaseConnection extends LakebaseConnectionInputs {
|
|
|
113
115
|
sslMode: SslMode;
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Capture AppKit's existing service-principal Lakebase pool.
|
|
120
|
+
*
|
|
121
|
+
* Call this during `setup:complete`, outside any `asUser()` scope. AppKit's
|
|
122
|
+
* exported pool routes dynamically by request context, but its runtime
|
|
123
|
+
* `activePool()` method returns the already-created underlying pool selected in
|
|
124
|
+
* the current context. Capturing it once at startup pins application-owned
|
|
125
|
+
* storage to the service principal without creating another pool or connection.
|
|
126
|
+
*
|
|
127
|
+
* A future AppKit version may export a fixed pool directly; in that case the
|
|
128
|
+
* pool has no `activePool()` method and is returned unchanged.
|
|
129
|
+
*/
|
|
130
|
+
export function captureServicePrincipalPool(pool: LakebasePool): LakebasePool {
|
|
131
|
+
const routingPool = pool as LakebasePool & { activePool?: () => LakebasePool };
|
|
132
|
+
return routingPool.activePool?.call(pool) ?? pool;
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
/**
|
|
117
136
|
* Every field of the Lakebase REST payloads is modelled as optional and
|
|
118
137
|
* validated at the boundary rather than asserted with a cast: the pickers below
|
|
@@ -778,7 +797,10 @@ function isAlreadyExistsError(error: unknown): boolean {
|
|
|
778
797
|
* echo request context, so it goes to the log instead.
|
|
779
798
|
*/
|
|
780
799
|
function operationFailed(opName: string, opError: unknown): ExecutionError {
|
|
781
|
-
logger.error("autopg: operation failed", {
|
|
800
|
+
logger.error("autopg: operation failed", {
|
|
801
|
+
operation: opName,
|
|
802
|
+
error: errorUtils.toError(opError),
|
|
803
|
+
});
|
|
782
804
|
const code = object.isRecord(opError) ? (opError.code ?? opError.reason) : undefined;
|
|
783
805
|
return new ExecutionError(`Lakebase operation failed: ${opName}`, {
|
|
784
806
|
context: {
|
package/src/migration.ts
CHANGED
|
@@ -31,10 +31,8 @@ export function handleOwnershipMigrationError(
|
|
|
31
31
|
const key = `${options.scope}\0${message}`;
|
|
32
32
|
if (loggedOwnershipErrors.has(key)) return true;
|
|
33
33
|
loggedOwnershipErrors.add(key);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
options.logger.warn(options.event, { error: message, ...options.context });
|
|
38
|
-
}
|
|
34
|
+
const error = errorUtils.toError(cause);
|
|
35
|
+
if (log.isLevelEnabled("debug")) options.logger.error(options.event, { error });
|
|
36
|
+
else options.logger.warn(options.event, { error, ...options.context });
|
|
39
37
|
return true;
|
|
40
38
|
}
|
package/src/plugin-execution.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** AppKit-specific adapters over the shared intercepted-execution contract. */
|
|
2
2
|
|
|
3
3
|
import { ExecutionError } from "@databricks/appkit";
|
|
4
|
-
import { execution, type Logger } from "@dbx-tools/shared-core";
|
|
4
|
+
import { errorUtils, execution, type Logger } from "@dbx-tools/shared-core";
|
|
5
5
|
|
|
6
6
|
/** Inputs for one plugin operation routed through AppKit interceptors. */
|
|
7
7
|
export interface RunPluginExecutionOptions<T, Settings> {
|
|
@@ -32,7 +32,7 @@ export function runPluginExecution<T, Settings>(
|
|
|
32
32
|
options.logger.warn("execution-failed", {
|
|
33
33
|
operation: failure.operation,
|
|
34
34
|
status: failure.status,
|
|
35
|
-
error: failure.message,
|
|
35
|
+
error: errorUtils.toError(failure.message),
|
|
36
36
|
});
|
|
37
37
|
return new ExecutionError(`${options.plugin}: ${failure.operation} failed`, {
|
|
38
38
|
context: { operation: failure.operation, status: failure.status },
|
package/src/provision.ts
CHANGED
|
@@ -128,7 +128,7 @@ export async function provisionCacheSchema(
|
|
|
128
128
|
} catch (err) {
|
|
129
129
|
logger.warn("autopg: cache provisioning failed (continuing)", {
|
|
130
130
|
schema: CACHE_SCHEMA,
|
|
131
|
-
error: errorUtils.
|
|
131
|
+
error: errorUtils.toError(err),
|
|
132
132
|
});
|
|
133
133
|
} finally {
|
|
134
134
|
await pool.end().catch(() => {});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Zod-owned operation contracts converted into native AppKit agent tools. */
|
|
2
|
+
|
|
3
|
+
import { defineTool, type ToolRegistry } from "@databricks/appkit/beta";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/** Metadata stored under `appkitTool` by a Zod schema's `.meta()` value. */
|
|
7
|
+
export interface AppKitToolMeta {
|
|
8
|
+
/** Opt in to exposing this operation as an agent tool. */
|
|
9
|
+
readonly agentTool?: boolean;
|
|
10
|
+
/** Tool side-effect classification. Defaults to `write`. */
|
|
11
|
+
readonly effect?: "read" | "write" | "destructive";
|
|
12
|
+
/** Require human approval before execution. */
|
|
13
|
+
readonly approval?: boolean;
|
|
14
|
+
/** Whether execution requires an authenticated user context. */
|
|
15
|
+
readonly requiresUserContext?: boolean;
|
|
16
|
+
/** Whether repeated calls with the same input are safe. */
|
|
17
|
+
readonly idempotent?: boolean;
|
|
18
|
+
/** Whether AppKit may inherit the tool automatically. */
|
|
19
|
+
readonly autoInheritable?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shape placed in a Zod schema's `.meta()` value. */
|
|
23
|
+
export interface AppKitOperationSchemaMeta {
|
|
24
|
+
readonly appkitTool?: AppKitToolMeta;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One typed operation backed by a single Zod input contract. */
|
|
28
|
+
export interface AppKitOperation<TSchema extends z.ZodType = z.ZodType, TResult = unknown> {
|
|
29
|
+
readonly schema: TSchema;
|
|
30
|
+
readonly execute: (input: z.output<TSchema>, signal?: AbortSignal) => TResult | Promise<TResult>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Preserve operation inference while declaring an exported operation map. */
|
|
34
|
+
export function defineOperations<
|
|
35
|
+
const TSchemas extends Readonly<Record<string, z.ZodType>>,
|
|
36
|
+
>(operations: {
|
|
37
|
+
readonly [TName in keyof TSchemas]: AppKitOperation<TSchemas[TName], unknown>;
|
|
38
|
+
}): typeof operations {
|
|
39
|
+
return operations;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Convert exported Zod operation contracts into a native AppKit tool registry. */
|
|
43
|
+
export function toolRegistryFromOperations(
|
|
44
|
+
operations: Readonly<Record<string, AppKitOperation<z.ZodType, unknown>>>,
|
|
45
|
+
): ToolRegistry {
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
Object.entries(operations).flatMap(([name, operation]) => {
|
|
48
|
+
const metadata = operation.schema.meta() as AppKitOperationSchemaMeta | undefined;
|
|
49
|
+
const tool = metadata?.appkitTool ?? {};
|
|
50
|
+
if (tool.agentTool !== true) return [];
|
|
51
|
+
const effect = tool.effect ?? "write";
|
|
52
|
+
const approval = tool.approval === true || effect === "destructive";
|
|
53
|
+
const description = operation.schema.description?.trim();
|
|
54
|
+
if (!description) {
|
|
55
|
+
throw new TypeError(`AppKit operation ${name} requires a Zod schema description`);
|
|
56
|
+
}
|
|
57
|
+
return [
|
|
58
|
+
[
|
|
59
|
+
name,
|
|
60
|
+
defineTool({
|
|
61
|
+
description,
|
|
62
|
+
schema: operation.schema,
|
|
63
|
+
annotations: {
|
|
64
|
+
effect,
|
|
65
|
+
...(approval ? { destructive: true } : {}),
|
|
66
|
+
...(tool.requiresUserContext === undefined
|
|
67
|
+
? {}
|
|
68
|
+
: { requiresUserContext: tool.requiresUserContext }),
|
|
69
|
+
...(tool.idempotent === undefined ? {} : { idempotent: tool.idempotent }),
|
|
70
|
+
},
|
|
71
|
+
...(tool.autoInheritable === undefined
|
|
72
|
+
? {}
|
|
73
|
+
: { autoInheritable: tool.autoInheritable }),
|
|
74
|
+
execute: (input, signal) => operation.execute(operation.schema.parse(input), signal),
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
];
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
}
|