@dbx-tools/appkit-mastra 0.9.74 → 0.9.93

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.
@@ -1,312 +1,347 @@
1
1
  /**
2
- * AppKit-backed caching for Databricks workspace skill mounts.
2
+ * User-scoped workspace skill catalogues cached as one semantic record.
3
3
  *
4
4
  * @module
5
5
  */
6
6
 
7
- import { randomUUID } from "node:crypto";
8
7
  import { CacheManager } from "@databricks/appkit";
9
- import { hash, object } from "@dbx-tools/shared-core";
10
- import type {
11
- CopyOptions,
12
- FileContent,
13
- FileEntry,
14
- FileStat,
15
- FileSystem,
16
- ListOptions,
17
- MakeDirectoryOptions,
18
- ReadFileOptions,
19
- RemoveOptions,
20
- WriteFileOptions,
21
- } from "@dbx-tools/shared-fs";
22
-
23
- import { filesystems, type MastraFileSystemAdapter } from "./filesystems.ts";
24
-
25
- const CACHE_NAMESPACE = "mastra:workspace-skills";
26
- const DEFAULT_MAX_SCOPED_MOUNTS = 256;
8
+ import { errorUtils, log, object } from "@dbx-tools/shared-core";
9
+ import { posixPath } from "@dbx-tools/shared-fs";
10
+ import type { RequestContext } from "@mastra/core/request-context";
11
+ import { LRUCache } from "lru-cache";
12
+ import { parse as parseYaml } from "yaml";
13
+ import { z } from "zod";
14
+
15
+ const CACHE_NAMESPACE = "mastra:workspace-skill-catalogue";
16
+ const CATALOGUE_VERSION = 1;
17
+ const MAX_SCOPED_CATALOGUES = 256;
18
+ const logger = log.logger("mastra/skill-cache");
19
+
20
+ const SkillCatalogueEntrySchema = z
21
+ .object({
22
+ name: z.string().describe("Unique skill name from SKILL.md frontmatter."),
23
+ description: z.string().describe("Skill description from SKILL.md frontmatter."),
24
+ instructions: z.string().describe("Markdown instructions below SKILL.md frontmatter."),
25
+ sourceId: z.string().describe("Identity of the mounted source that owns this skill."),
26
+ sourcePath: z.string().describe("Path to SKILL.md relative to its source root."),
27
+ signature: z
28
+ .string()
29
+ .optional()
30
+ .describe("Stable metadata signature used to retain unchanged instructions."),
31
+ })
32
+ .describe("One parsed skill in a materialized workspace catalogue.");
33
+
34
+ const SkillCatalogueSchema = z
35
+ .object({
36
+ version: z.literal(CATALOGUE_VERSION).describe("Persisted catalogue schema version."),
37
+ generatedAt: z.string().datetime().describe("Time the catalogue was last refreshed."),
38
+ roots: z.array(z.string()).describe("Ordered source identities included in the catalogue."),
39
+ skills: z
40
+ .array(SkillCatalogueEntrySchema)
41
+ .describe("Parsed skills after ordered duplicate-name precedence is applied."),
42
+ })
43
+ .describe("One user-scoped materialized workspace skill catalogue.");
44
+
45
+ /** Parsed skill retained in a materialized workspace catalogue. */
46
+ export type SkillCatalogueEntry = z.infer<typeof SkillCatalogueEntrySchema>;
47
+
48
+ /** Versioned persisted skill catalogue. */
49
+ export type SkillCatalogue = z.infer<typeof SkillCatalogueSchema>;
50
+
51
+ /** One ordered filesystem root contributing skills to a catalogue. */
52
+ export interface SkillCatalogueSource {
53
+ /** Stable source identity including its mount precedence. */
54
+ id: string;
55
+ /** List one source-relative directory with provider metadata when available. */
56
+ list(path: string): Promise<SkillCatalogueFileEntry[]>;
57
+ /** Read one source-relative file fresh from its owning filesystem. */
58
+ read(path: string): Promise<string>;
59
+ }
27
60
 
28
- interface CachedFileStat extends Omit<FileStat, "accessedAt" | "createdAt" | "modifiedAt"> {
29
- accessedAt?: number;
30
- createdAt?: number;
31
- modifiedAt?: number;
61
+ /** Minimal directory metadata used to compare one catalogue refresh. */
62
+ export interface SkillCatalogueFileEntry {
63
+ metadata?: Readonly<Record<string, unknown>>;
64
+ name: string;
65
+ size?: number;
66
+ type: "directory" | "file";
32
67
  }
33
68
 
34
- interface ScopedMountEntry {
35
- adapter: MastraFileSystemAdapter;
36
- cache: AppKitCachedFileSystem;
69
+ /** Options for one user-scoped workspace skill catalogue. */
70
+ export interface WorkspaceSkillCatalogueOptions {
37
71
  host: string;
38
- root: string;
72
+ sources: readonly SkillCatalogueSource[];
73
+ ttlMs?: number;
39
74
  userKey: string;
40
75
  }
41
76
 
42
- const scopedMounts = new Map<string, ScopedMountEntry>();
77
+ /** Request state used to resolve one user-scoped catalogue owner. */
78
+ export interface SkillCatalogueResolveContext {
79
+ abortSignal?: AbortSignal;
80
+ requestContext?: RequestContext;
81
+ }
43
82
 
44
- /** Default remote skill metadata and content cache lifetime. */
45
- export const DEFAULT_WORKSPACE_SKILL_CACHE_TTL_MS = 5 * 60 * 1000;
83
+ /** Resolve one request's complete skill catalogue. */
84
+ export type SkillCatalogueResolver = (
85
+ context: SkillCatalogueResolveContext,
86
+ ) => Promise<WorkspaceSkillCatalogue>;
46
87
 
47
- /** Options for one AppKit-cached workspace skill filesystem. */
48
- export interface AppKitCachedFileSystemOptions {
49
- host: string;
50
- source: FileSystem;
51
- ttlMs?: number;
52
- userKey: string;
88
+ interface ParsedSkill {
89
+ description: string;
90
+ instructions: string;
91
+ name: string;
53
92
  }
54
93
 
55
- /** Stable Mastra mount plus the identity key used for source reuse. */
56
- export interface CachedWorkspaceSkillMount {
57
- cacheKey: string;
58
- filesystem: MastraFileSystemAdapter;
59
- }
94
+ const scopedCatalogues = new LRUCache<string, WorkspaceSkillCatalogue>({
95
+ max: MAX_SCOPED_CATALOGUES,
96
+ });
60
97
 
61
- /**
62
- * Cache read-only filesystem operations through AppKit while delegating
63
- * mutations to the current request's source.
64
- */
65
- export class AppKitCachedFileSystem implements FileSystem<"appkit-cache"> {
66
- readonly backend = "appkit-cache" as const;
67
- readonly id: string;
68
- readonly readOnly: boolean;
69
- readonly root: string;
98
+ /** Default lifetime for one complete workspace skill catalogue. */
99
+ export const DEFAULT_WORKSPACE_SKILL_CACHE_TTL_MS = 5 * 60 * 1000;
100
+
101
+ /** Materialized catalogue with one AppKit cache record and fresh auxiliary reads. */
102
+ export class WorkspaceSkillCatalogue {
103
+ readonly cacheKey: string;
104
+ readonly host: string;
105
+ readonly userKey: string;
70
106
 
71
- private source: FileSystem;
72
- private readonly host: string;
73
- private readonly ttlSec: number;
74
- private readonly userKey: string;
107
+ private lastValid?: SkillCatalogue;
108
+ private readonly ttlMs: number;
109
+ private sources: readonly SkillCatalogueSource[];
75
110
 
76
- constructor(options: AppKitCachedFileSystemOptions) {
111
+ constructor(private readonly options: WorkspaceSkillCatalogueOptions) {
77
112
  this.host = options.host;
78
- this.source = options.source;
79
- this.root = options.source.root;
80
- this.readOnly = options.source.readOnly;
81
- this.ttlSec = cacheTtlSec(options.ttlMs);
82
113
  this.userKey = options.userKey;
83
- this.id = `appkit-skill-cache-${hash.fnvHash(this.host, this.userKey, this.root)}`;
114
+ this.sources = options.sources;
115
+ this.ttlMs = positiveTtl(options.ttlMs);
116
+ this.cacheKey = object.toStableKey({
117
+ host: options.host,
118
+ roots: options.sources.map(({ id }) => id),
119
+ userKey: options.userKey,
120
+ version: CATALOGUE_VERSION,
121
+ });
84
122
  }
85
123
 
86
- /** Rebind cache misses and writes to the current request's OBO filesystem. */
87
- bind(source: FileSystem): void {
88
- if (source.root !== this.root || source.readOnly !== this.readOnly) {
89
- throw new Error("Cannot rebind a workspace skill cache to a different filesystem scope");
124
+ /** Rebind refreshes and lazy reads to the current request's filesystem clients. */
125
+ bind(sources: readonly SkillCatalogueSource[]): void {
126
+ const identities = sources.map(({ id }) => id);
127
+ if (object.toStableKey(identities) !== object.toStableKey(this.sources.map(({ id }) => id))) {
128
+ throw new Error("Cannot rebind a skill catalogue to a different ordered root set");
90
129
  }
91
- this.source = source;
130
+ this.sources = sources;
92
131
  }
93
132
 
94
- async init(): Promise<void> {
95
- await this.source.init();
96
- }
97
-
98
- /** Keep the shared scope alive when one request releases its workspace view. */
99
- close(): Promise<void> {
100
- return Promise.resolve();
101
- }
102
-
103
- resolvePath(inputPath: string): string {
104
- return this.source.resolvePath(inputPath);
133
+ /** Return the cached catalogue, refreshing all roots once on cache miss. */
134
+ async get(signal?: AbortSignal): Promise<SkillCatalogue> {
135
+ const cache = CacheManager.getInstanceSync();
136
+ try {
137
+ const catalogue = SkillCatalogueSchema.parse(
138
+ await cache.getOrExecute(
139
+ [CACHE_NAMESPACE, CATALOGUE_VERSION, this.options.host, this.rootKey()],
140
+ () => this.refresh(),
141
+ this.options.userKey,
142
+ {
143
+ ttl: Math.max(1, Math.ceil(this.ttlMs / 1000)),
144
+ callerSignal: signal,
145
+ },
146
+ ),
147
+ );
148
+ this.lastValid = catalogue;
149
+ return catalogue;
150
+ } catch (error) {
151
+ if (!this.lastValid) throw error;
152
+ logger.warn("skill catalogue refresh failed; retaining last valid catalogue", {
153
+ error: errorUtils.errorMessage(error),
154
+ roots: this.sources.map(({ id }) => id),
155
+ });
156
+ return this.lastValid;
157
+ }
105
158
  }
106
159
 
107
- async readFile(inputPath: string): Promise<Uint8Array>;
108
- async readFile(
109
- inputPath: string,
110
- options: ReadFileOptions & { encoding: string },
111
- ): Promise<string>;
112
- async readFile(inputPath: string, options?: ReadFileOptions): Promise<string | Uint8Array> {
113
- const value = await this.cached("readFile", inputPath, undefined, async (source) => ({
114
- base64: Buffer.from(await source.readFile(inputPath)).toString("base64"),
115
- }));
116
- const bytes = Buffer.from(value.base64, "base64");
117
- return options?.encoding ? bytes.toString(options.encoding as BufferEncoding) : bytes;
160
+ /** Expire this complete catalogue without touching unrelated cache entries. */
161
+ async invalidate(): Promise<void> {
162
+ const cache = CacheManager.getInstanceSync();
163
+ await cache.delete(this.persistentKey(cache));
118
164
  }
119
165
 
120
- async writeFile(
166
+ /** Read one selected skill's auxiliary file directly from its current source. */
167
+ async readSkillFile(
168
+ skillName: string,
121
169
  inputPath: string,
122
- content: FileContent,
123
- options?: WriteFileOptions,
124
- ): Promise<void> {
125
- await this.mutate((source) => source.writeFile(inputPath, content, options));
126
- }
127
-
128
- async appendFile(inputPath: string, content: FileContent): Promise<void> {
129
- await this.mutate((source) => source.appendFile(inputPath, content));
130
- }
131
-
132
- async deleteFile(inputPath: string, options?: RemoveOptions): Promise<void> {
133
- await this.mutate((source) => source.deleteFile(inputPath, options));
134
- }
135
-
136
- async copyFile(
137
- sourcePath: string,
138
- destinationPath: string,
139
- options?: CopyOptions,
140
- ): Promise<void> {
141
- await this.mutate((source) => source.copyFile(sourcePath, destinationPath, options));
142
- }
143
-
144
- async moveFile(
145
- sourcePath: string,
146
- destinationPath: string,
147
- options?: CopyOptions,
148
- ): Promise<void> {
149
- await this.mutate((source) => source.moveFile(sourcePath, destinationPath, options));
150
- }
151
-
152
- async mkdir(inputPath: string, options?: MakeDirectoryOptions): Promise<void> {
153
- await this.mutate((source) => source.mkdir(inputPath, options));
154
- }
155
-
156
- async rmdir(inputPath: string, options?: RemoveOptions): Promise<void> {
157
- await this.mutate((source) => source.rmdir(inputPath, options));
158
- }
159
-
160
- async readdir(inputPath: string, options?: ListOptions): Promise<FileEntry[]> {
161
- return this.cached("readdir", inputPath, options, (source) =>
162
- source.readdir(inputPath, options),
163
- );
164
- }
165
-
166
- async exists(inputPath: string): Promise<boolean> {
167
- return this.cached("exists", inputPath, undefined, (source) => source.exists(inputPath));
170
+ signal?: AbortSignal,
171
+ ): Promise<string> {
172
+ signal?.throwIfAborted();
173
+ const catalogue = await this.get(signal);
174
+ const skill = catalogue.skills.find(({ name }) => name === skillName);
175
+ if (!skill) throw new Error(`Unknown skill: ${skillName}`);
176
+ const normalized = posixPath.normalize(inputPath);
177
+ if (
178
+ !normalized.ok ||
179
+ normalized.path === "/" ||
180
+ inputPath.split("/").some((segment) => segment === "..")
181
+ ) {
182
+ throw new Error(`Invalid skill file path: ${inputPath}`);
183
+ }
184
+ const source = this.sources.find(({ id }) => id === skill.sourceId);
185
+ if (!source) throw new Error(`Skill source is no longer mounted: ${skill.sourceId}`);
186
+ const parent = skill.sourcePath.split("/").slice(0, -1).join("/");
187
+ const path = [parent, normalized.path.replace(/^\/+/, "")].filter(Boolean).join("/");
188
+ return source.read(path);
168
189
  }
169
190
 
170
- async stat(inputPath: string): Promise<FileStat> {
171
- const value = await this.cached("stat", inputPath, undefined, async (source) =>
172
- serializeStat(await source.stat(inputPath)),
191
+ private async refresh(): Promise<SkillCatalogue> {
192
+ const previous = new Map(
193
+ this.lastValid?.skills.map((skill) => [`${skill.sourceId}\0${skill.sourcePath}`, skill]) ?? [],
173
194
  );
174
- return deserializeStat(value);
175
- }
176
-
177
- /** Rotate this scope's cache generation so the next read reloads it. */
178
- async invalidate(): Promise<void> {
179
- const cache = await CacheManager.getInstance();
180
- await cache.set(this.generationKey(cache), randomUUID(), {
181
- ttl: this.ttlSec * 2,
195
+ const selected = new Map<string, SkillCatalogueEntry>();
196
+ for (const source of this.sources) {
197
+ for (const candidate of await discoverSkillFiles(source)) {
198
+ const prior = previous.get(`${source.id}\0${candidate.path}`);
199
+ const signature = fileSignature(candidate.entry);
200
+ let parsed: ParsedSkill | undefined;
201
+ if (signature && prior?.signature === signature) {
202
+ parsed = prior;
203
+ } else {
204
+ parsed = parseSkill(
205
+ await source.read(candidate.path),
206
+ candidate.path,
207
+ );
208
+ }
209
+ if (!parsed || selected.has(parsed.name)) continue;
210
+ selected.set(parsed.name, {
211
+ ...parsed,
212
+ sourceId: source.id,
213
+ sourcePath: candidate.path,
214
+ ...(signature ? { signature } : {}),
215
+ });
216
+ }
217
+ }
218
+ return SkillCatalogueSchema.parse({
219
+ version: CATALOGUE_VERSION,
220
+ generatedAt: new Date().toISOString(),
221
+ roots: this.sources.map(({ id }) => id),
222
+ skills: [...selected.values()],
182
223
  });
183
224
  }
184
225
 
185
- private async cached<T>(
186
- operation: string,
187
- inputPath: string,
188
- options: unknown,
189
- load: (source: FileSystem) => Promise<T>,
190
- ): Promise<T> {
191
- const source = this.source;
192
- const cache = await CacheManager.getInstance();
193
- const generation = await this.generation(cache);
194
- return cache.getOrExecute(
195
- [
196
- CACHE_NAMESPACE,
197
- this.host,
198
- this.root,
199
- generation,
200
- operation,
201
- source.resolvePath(inputPath),
202
- object.toStableKey(options ?? {}),
203
- ],
204
- () => load(source),
205
- this.userKey,
206
- { ttl: this.ttlSec },
207
- );
226
+ private rootKey(): string {
227
+ return object.toStableKey(this.sources.map(({ id }) => id));
208
228
  }
209
229
 
210
- private async generation(cache: CacheManager): Promise<string> {
211
- return cache.getOrExecute(
212
- [CACHE_NAMESPACE, "generation", this.host, this.root],
213
- async () => randomUUID(),
214
- this.userKey,
215
- { ttl: this.ttlSec * 2 },
230
+ private persistentKey(cache: CacheManager): string {
231
+ return cache.generateKey(
232
+ [CACHE_NAMESPACE, CATALOGUE_VERSION, this.options.host, this.rootKey()],
233
+ this.options.userKey,
216
234
  );
217
235
  }
218
-
219
- private generationKey(cache: CacheManager): string {
220
- return cache.generateKey([CACHE_NAMESPACE, "generation", this.host, this.root], this.userKey);
221
- }
222
-
223
- private async mutate(run: (source: FileSystem) => Promise<void>): Promise<void> {
224
- const source = this.source;
225
- await run(source);
226
- await this.invalidate();
227
- }
228
236
  }
229
237
 
230
- /** Return one stable Mastra mount per resolved user, host, and source root. */
231
- export function cachedWorkspaceSkillMount(
232
- options: AppKitCachedFileSystemOptions,
233
- ): CachedWorkspaceSkillMount {
234
- const cacheKey = object.toStableKey({
238
+ /** Return one stable catalogue owner for a user, host, and ordered root set. */
239
+ export function workspaceSkillCatalogue(
240
+ options: WorkspaceSkillCatalogueOptions,
241
+ ): WorkspaceSkillCatalogue {
242
+ const key = object.toStableKey({
235
243
  host: options.host,
236
- readOnly: options.source.readOnly,
237
- root: options.source.root,
244
+ roots: options.sources.map(({ id }) => id),
238
245
  userKey: options.userKey,
239
246
  });
240
- const existing = scopedMounts.get(cacheKey);
247
+ const existing = scopedCatalogues.get(key);
241
248
  if (existing) {
242
- existing.cache.bind(options.source);
243
- scopedMounts.delete(cacheKey);
244
- scopedMounts.set(cacheKey, existing);
245
- return { cacheKey, filesystem: existing.adapter };
249
+ existing.bind(options.sources);
250
+ return existing;
246
251
  }
247
-
248
- const cache = new AppKitCachedFileSystem(options);
249
- const entry: ScopedMountEntry = {
250
- adapter: filesystems(cache, {
251
- id: cache.id,
252
- readOnly: options.source.readOnly,
253
- }),
254
- cache,
255
- host: options.host,
256
- root: options.source.root,
257
- userKey: options.userKey,
258
- };
259
- scopedMounts.set(cacheKey, entry);
260
- trimScopedMounts();
261
- return { cacheKey, filesystem: entry.adapter };
252
+ const catalogue = new WorkspaceSkillCatalogue(options);
253
+ scopedCatalogues.set(key, catalogue);
254
+ return catalogue;
262
255
  }
263
256
 
264
- /** Invalidate one user's cached workspace skill scope. */
257
+ /** Invalidate matching complete catalogues and retain their stable owners. */
265
258
  export async function clearWorkspaceSkillCache(options: {
266
259
  host: string;
267
- root?: string;
268
- userKey: string;
260
+ userKey?: string;
269
261
  }): Promise<void> {
270
- const matches = [...scopedMounts.entries()].filter(
271
- ([, entry]) =>
272
- entry.host === options.host &&
273
- entry.userKey === options.userKey &&
274
- (options.root === undefined || entry.root === options.root),
262
+ const matches = [...scopedCatalogues.values()].filter(
263
+ (catalogue) =>
264
+ catalogue.host === options.host &&
265
+ (options.userKey === undefined || catalogue.userKey === options.userKey),
275
266
  );
276
- await Promise.all(matches.map(([, entry]) => entry.cache.invalidate()));
267
+ await Promise.all(matches.map((catalogue) => catalogue.invalidate()));
277
268
  }
278
269
 
279
- function cacheTtlSec(ttlMs = DEFAULT_WORKSPACE_SKILL_CACHE_TTL_MS): number {
280
- if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
281
- throw new Error("Workspace skill cache ttlMs must be a positive finite number");
270
+ async function discoverSkillFiles(
271
+ source: SkillCatalogueSource,
272
+ ): Promise<Array<{ entry: SkillCatalogueFileEntry; path: string }>> {
273
+ let rootEntries: SkillCatalogueFileEntry[];
274
+ try {
275
+ rootEntries = await source.list(".");
276
+ } catch (error) {
277
+ if (errorUtils.errorContext(error).notAccessible) return [];
278
+ throw error;
282
279
  }
283
- return Math.max(1, Math.ceil(ttlMs / 1000));
280
+ const candidates: Array<{ entry: SkillCatalogueFileEntry; path: string }> = [];
281
+ const rootSkill = rootEntries.find(
282
+ ({ name, type }) => name.toLowerCase() === "skill.md" && type === "file",
283
+ );
284
+ if (rootSkill) candidates.push({ entry: rootSkill, path: rootSkill.name });
285
+ await Promise.all(
286
+ rootEntries
287
+ .filter(({ type }) => type === "directory")
288
+ .map(async (directory) => {
289
+ const entries = await source.list(directory.name);
290
+ const skill = entries.find(
291
+ ({ name, type }) => name.toLowerCase() === "skill.md" && type === "file",
292
+ );
293
+ if (skill) candidates.push({ entry: skill, path: posixPath.join(directory.name, skill.name) });
294
+ }),
295
+ );
296
+ return candidates.sort((left, right) => left.path.localeCompare(right.path));
284
297
  }
285
298
 
286
- function serializeStat(value: FileStat): CachedFileStat {
287
- const { accessedAt, createdAt, modifiedAt, ...stat } = value;
299
+ function parseSkill(source: string, path: string): ParsedSkill | undefined {
300
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)([\s\S]*)$/.exec(source);
301
+ if (!match) {
302
+ logger.warn("skill skipped (missing YAML frontmatter)", { path });
303
+ return undefined;
304
+ }
305
+ const metadata = parseYaml(match[1]!) as unknown;
306
+ if (!object.isRecord(metadata)) {
307
+ logger.warn("skill skipped (invalid YAML frontmatter)", { path });
308
+ return undefined;
309
+ }
310
+ const parsed = z
311
+ .object({
312
+ name: z.string().trim().min(1),
313
+ description: z.string().trim().min(1),
314
+ })
315
+ .safeParse(metadata);
316
+ if (!parsed.success) {
317
+ logger.warn("skill skipped (invalid metadata)", {
318
+ path,
319
+ error: parsed.error.message,
320
+ });
321
+ return undefined;
322
+ }
288
323
  return {
289
- ...stat,
290
- ...(accessedAt ? { accessedAt: accessedAt.getTime() } : {}),
291
- ...(createdAt ? { createdAt: createdAt.getTime() } : {}),
292
- ...(modifiedAt ? { modifiedAt: modifiedAt.getTime() } : {}),
324
+ name: parsed.data.name,
325
+ description: parsed.data.description,
326
+ instructions: match[2]!.trim(),
293
327
  };
294
328
  }
295
329
 
296
- function deserializeStat(value: CachedFileStat): FileStat {
297
- const { accessedAt, createdAt, modifiedAt, ...stat } = value;
298
- return {
299
- ...stat,
300
- ...(accessedAt !== undefined ? { accessedAt: new Date(accessedAt) } : {}),
301
- ...(createdAt !== undefined ? { createdAt: new Date(createdAt) } : {}),
302
- ...(modifiedAt !== undefined ? { modifiedAt: new Date(modifiedAt) } : {}),
330
+ function fileSignature(entry: SkillCatalogueFileEntry): string | undefined {
331
+ const metadata = object.isRecord(entry.metadata) ? entry.metadata : {};
332
+ const values = {
333
+ modifiedAt: metadata.modifiedAt,
334
+ objectId: metadata.objectId,
335
+ size: entry.size,
303
336
  };
337
+ return Object.values(values).some((value) => value !== undefined)
338
+ ? object.toStableKey(values)
339
+ : undefined;
304
340
  }
305
341
 
306
- function trimScopedMounts(): void {
307
- while (scopedMounts.size > DEFAULT_MAX_SCOPED_MOUNTS) {
308
- const oldest = scopedMounts.keys().next().value;
309
- if (oldest === undefined) return;
310
- scopedMounts.delete(oldest);
342
+ function positiveTtl(value = DEFAULT_WORKSPACE_SKILL_CACHE_TTL_MS): number {
343
+ if (!Number.isFinite(value) || value <= 0) {
344
+ throw new RangeError("Workspace skill catalogue ttlMs must be a positive finite number");
311
345
  }
346
+ return value;
312
347
  }