Skip to content

Commit 19018f0

Browse files
NikAiyerMastra Code (anthropic/claude-opus-4-7)
andauthored
feat(core,editor): editor namespace polish + SkillPublishResult.files (#16673)
## Summary Carves PARA-3 (Option B + D) from the `yj/magnificent-marquess` feature branch — a small, plumbing-only slice of editor namespace fixes that don't depend on STACK-1 (server permission foundation) or the agent-builder runtime. This is foundation work for the agent-builder feature branch but ships independently with no user-visible behavior changes. ## What's in this PR ### `@mastra/core` (publish.ts) - `SkillPublishResult` now includes a `files` field with the full skill file tree and base64-encoded blob content (`StorageSkillFileNode[]`). - Adds `parseSkillSnapshotFromFiles()` helper and `SkillSnapshotFile` type for parsing skill snapshot frontmatter from flat file lists. ### `@mastra/editor` - **base.ts**: `CrudEditorNamespace.clearCache(id)` always calls `onCacheEvict(id)`, even when the entry wasn't cached. Lets subclasses clean up runtime registries (e.g. `mastra.#agents`) for version-specific lookups that bypass the editor cache. - **skill.ts**: Stores the new `files` field on publish, and strips `undefined` keys from the snapshot before `update()` so libsql/pg adapters don't reject `undefined` as a bind argument. - **workspace.ts**: Adds `snapshotFromWorkspace()` and `serializeFilesystem()` — the reverse of `hydrateSnapshotToWorkspace()` — to round-trip a live `Workspace` instance (`CompositeFilesystem` mounts, sandbox provider, tools config) into a `StorageWorkspaceSnapshotType` for persistence. ## What's NOT in this PR Deferred to later PRs (Option A, agent-builder-bound): - `agent.ts` namespace builder defaults / model policy / browser refs / visibility (~241 lines) - `favorites.ts` namespace + tests (STACK-3) - `MastraEditor` class extensions (`__browsers`, `__builderConfig`, `ensureBuilderWorkspaces()`, etc.) - `IMastraEditor` browser/builder method implementations - `BrowserProvider` type, `StorageBrowserRef`, `MastraBrowser` ## Dependencies - Branches off `main`, no STACK-1 dependency. - No runtime impact on existing users: - `SkillPublishResult.files` is purely additive — existing `{ snapshot, tree }` destructures keep working. - `clearCache()` change is a no-op for subclasses that don't override `onCacheEvict()`. - `snapshotFromWorkspace()` is a new method, no existing callers. ## Test plan - `pnpm --filter @mastra/core typecheck` — clean - `pnpm --filter @mastra/editor typecheck` — clean - `pnpm --filter @mastra/editor test` — 372 passed / 13 skipped (385 total) ## Changesets - `silly-wolves-dance.md` — `@mastra/core` minor (publish.ts `files` field + helpers) - `brave-tigers-cheer.md` — `@mastra/editor` minor (namespace polish) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 This PR improves how the editor handles skill files and workspace snapshots: it records the full file tree for published skills, makes cache eviction reliably trigger cleanup hooks, and adds a way to serialize a live workspace for storage. These are internal plumbing changes to prepare for agent-builder work and don't change user-facing behavior. --- ## Changes Summary ### `@mastra/core` – `packages/core/src/workspace/skills/publish.ts` - Added `files: StorageSkillFileNode[]` to `SkillPublishResult` — a UI-facing nested file tree with base64-encoded content for each file. - Exported `parseSkillSnapshotFromFiles(files: SkillSnapshotFile[])` to parse SKILL.md frontmatter and derive denormalized snapshot fields from a flat file list; introduced the `SkillSnapshotFile` type. - `collectSkillForPublish()` / `publishSkillFromSource()` now build the nested file nodes (including base64 blobs for binary files) and return `files`; missing SKILL.md is rethrown with a clearer error. ### `@mastra/editor` – `packages/editor/src/namespaces/base.ts` - `CrudEditorNamespace.clearCache(id)` now always calls `onCacheEvict(id)` even when the id wasn't present in the cache, allowing subclasses to perform runtime cleanup for entries that may bypass the cache. ### `@mastra/editor` – `packages/editor/src/namespaces/skill.ts` - `EditorSkillNamespace.publish` captures the new `files` from `publishSkillFromSource()` and persists it on the skill record. - Before calling `skillStore.update()`, builds a `snapshotUpdate` object that strips keys with `undefined` values to avoid adapter bind errors (libsql/pg). ### `@mastra/editor` – `packages/editor/src/namespaces/workspace.ts` - Added `snapshotFromWorkspace(workspace: Workspace): Promise<StorageWorkspaceSnapshotType>` to serialize a live Workspace into a storage snapshot. - Added `serializeFilesystem()` helper to serialize primary or mounted filesystems, calling `fs.getInfo()` when available to preserve provider metadata. - Serializes sandbox info via `sandbox.getInfo()` (when present) and copies static workspace tools settings (`enabled`, `requireApproval`) into the stored tools config. --- ## Compatibility & Safety - Additive, backward-compatible API change: `SkillPublishResult.files` is optional/additive for callers that ignore it. - `clearCache()` behavior is a no-op for subclasses that don't override `onCacheEvict()`. - `snapshotFromWorkspace()` is new and has no existing callers. - No STACK-1 dependency; branches off main. - Typechecks pass for `@mastra/core` and `@mastra/editor`. Tests: 372 passed / 13 skipped. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/mastra-ai/mastra/pull/16673?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
1 parent 4e88dc6 commit 19018f0

6 files changed

Lines changed: 226 additions & 36 deletions

File tree

.changeset/brave-tigers-cheer.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@mastra/editor': minor
3+
---
4+
5+
`EditorWorkspaceNamespace` can now snapshot a live `Workspace` for persistence — the reverse of `hydrateSnapshotToWorkspace()`:
6+
7+
```ts
8+
const snapshot = await editor.workspace.snapshotFromWorkspace(runtimeWorkspace);
9+
await editor.workspace.create({ id: 'my-workspace', ...snapshot });
10+
```
11+
12+
`snapshotFromWorkspace()` is `async` and awaits `sandbox.getInfo()` and `filesystem.getInfo()` so async providers like `CompositeFilesystem` keep their mount metadata in the stored config.
13+
14+
Also includes two smaller behavioral fixes:
15+
16+
- `EditorSkillNamespace.publishSkillFromSource()` stores the new `files` field on the published skill version and strips `undefined` keys before calling `update()` (libsql/pg adapters reject `undefined` bind arguments).
17+
- `CrudEditorNamespace.clearCache(id)` always calls `onCacheEvict(id)`, even when the entity wasn't cached, so subclasses can clean up runtime registries for version-specific lookups that bypass the editor cache.

.changeset/silly-wolves-dance.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@mastra/core': minor
3+
---
4+
5+
`publishSkillFromSource()` (and `collectSkillForPublish()`) now return a `files` field containing the full skill source as a tree of `StorageSkillFileNode` entries with base64-encoded blob content — handy for storing a UI-facing copy of a skill alongside its content-addressable tree:
6+
7+
```ts
8+
const { snapshot, tree, files } = await publishSkillFromSource({ source });
9+
// files: StorageSkillFileNode[] — name, mimeType, base64 content per node
10+
```
11+
12+
Existing callers that only destructure `{ snapshot, tree }` are unaffected; the field is additive.
13+
14+
Also adds `parseSkillSnapshotFromFiles()` for parsing skill snapshot frontmatter from a flat file list (used by the registry install flow):
15+
16+
```ts
17+
import { parseSkillSnapshotFromFiles, type SkillSnapshotFile } from '@mastra/core/workspace';
18+
19+
const files: SkillSnapshotFile[] = [{ path: 'SKILL.md', content: '...' }, ...];
20+
const snapshot = parseSkillSnapshotFromFiles(files);
21+
```

packages/core/src/workspace/skills/publish.ts

Lines changed: 104 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
SkillVersionTree,
66
SkillVersionTreeEntry,
77
StorageBlobEntry,
8+
StorageSkillFileNode,
89
StorageSkillSnapshotType,
910
} from '../../storage/types';
1011
import type { SkillSource, SkillSourceEntry } from './skill-source';
@@ -20,6 +21,8 @@ export interface SkillPublishResult {
2021
tree: SkillVersionTree;
2122
/** Blob entries to store (already deduplicated by hash) */
2223
blobs: StorageBlobEntry[];
24+
/** UI-facing nested file tree (folders + files with content) for the stored skill record */
25+
files: StorageSkillFileNode[];
2326
}
2427

2528
// =============================================================================
@@ -144,10 +147,99 @@ function collectSubdirPaths(allPaths: string[], subdir: string): string[] {
144147
return allPaths.filter(p => p.startsWith(prefix)).map(p => p.substring(prefix.length));
145148
}
146149

150+
/**
151+
* Build a nested folder/file tree from a flat list of walked files for the
152+
* UI-facing `files` column on the stored skill record. Binary file content is
153+
* base64-encoded so it can round-trip through the string-typed `content` field.
154+
*/
155+
function buildSkillFileNodes(files: WalkedFile[]): StorageSkillFileNode[] {
156+
const root: StorageSkillFileNode[] = [];
157+
158+
for (const file of files) {
159+
const segments = file.path.split('/').filter(Boolean);
160+
if (segments.length === 0) continue;
161+
162+
let cursor = root;
163+
for (let i = 0; i < segments.length - 1; i++) {
164+
const segment = segments[i]!;
165+
let folder = cursor.find(node => node.type === 'folder' && node.name === segment);
166+
if (!folder) {
167+
folder = { name: segment, type: 'folder', children: [] };
168+
cursor.push(folder);
169+
}
170+
if (!folder.children) folder.children = [];
171+
cursor = folder.children;
172+
}
173+
174+
const fileName = segments[segments.length - 1]!;
175+
const content = file.isBinary
176+
? (Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content as string)).toString('base64')
177+
: (file.content as string);
178+
cursor.push({ name: fileName, type: 'file', content });
179+
}
180+
181+
return root;
182+
}
183+
147184
// =============================================================================
148185
// Public API
149186
// =============================================================================
150187

188+
/**
189+
* A flat file entry used by snapshot parsing helpers.
190+
* Path is the skill-relative path (e.g. `SKILL.md`, `references/foo.md`).
191+
*/
192+
export interface SkillSnapshotFile {
193+
path: string;
194+
content: string | Buffer;
195+
}
196+
197+
/**
198+
* Parse a flat array of skill files into a denormalized snapshot.
199+
*
200+
* Finds `SKILL.md`, parses its YAML frontmatter into structured fields
201+
* (name, description, license, compatibility, metadata), and uses the
202+
* markdown body as `instructions`. Discovers `references/`, `scripts/`,
203+
* and `assets/` subdirectory paths from the file list.
204+
*
205+
* Used by both the publish flow (which has files from a SkillSource walk)
206+
* and the registry install flow (which has files fetched from an external
207+
* registry like skills.sh). The Agent Skills spec puts metadata in
208+
* frontmatter and agent-facing prose in the body — this helper enforces
209+
* that split so frontmatter never leaks into the runtime instructions.
210+
*
211+
* @throws if `SKILL.md` is missing from the file list
212+
*/
213+
export function parseSkillSnapshotFromFiles(files: SkillSnapshotFile[]): Omit<StorageSkillSnapshotType, 'tree'> {
214+
const skillMdFile = files.find(f => f.path === 'SKILL.md');
215+
if (!skillMdFile) {
216+
throw new Error('SKILL.md not found in skill files');
217+
}
218+
219+
const skillMdContent =
220+
typeof skillMdFile.content === 'string' ? skillMdFile.content : skillMdFile.content.toString('utf-8');
221+
const parsed = matter(skillMdContent);
222+
const frontmatter = parsed.data;
223+
const instructions = parsed.content.trim();
224+
225+
const allPaths = files.map(f => f.path);
226+
const references = collectSubdirPaths(allPaths, 'references');
227+
const scripts = collectSubdirPaths(allPaths, 'scripts');
228+
const assets = collectSubdirPaths(allPaths, 'assets');
229+
230+
return {
231+
name: frontmatter.name,
232+
description: frontmatter.description,
233+
instructions,
234+
license: frontmatter.license,
235+
compatibility: frontmatter.compatibility,
236+
metadata: frontmatter.metadata,
237+
...(references.length > 0 ? { references } : {}),
238+
...(scripts.length > 0 ? { scripts } : {}),
239+
...(assets.length > 0 ? { assets } : {}),
240+
};
241+
}
242+
151243
/**
152244
* Collect a skill from a SkillSource for publishing.
153245
* Walks the skill directory, hashes all files, parses SKILL.md frontmatter,
@@ -216,37 +308,21 @@ export async function collectSkillForPublish(source: SkillSource, skillPath: str
216308

217309
const tree: SkillVersionTree = { entries: treeEntries };
218310
const blobs = Array.from(blobMap.values());
311+
const fileNodes = buildSkillFileNodes(files);
219312

220-
// 3. Parse SKILL.md with gray-matter for denormalized fields
221-
const skillMdFile = files.find(f => f.path === 'SKILL.md');
222-
if (!skillMdFile) {
223-
throw new Error(`SKILL.md not found in ${skillPath}`);
313+
// 3. Parse SKILL.md frontmatter and discover references/scripts/assets paths
314+
let snapshot: Omit<StorageSkillSnapshotType, 'tree'>;
315+
try {
316+
snapshot = parseSkillSnapshotFromFiles(files);
317+
} catch (err) {
318+
// Surface the skill path to make the error easier to debug
319+
if (err instanceof Error && err.message.includes('SKILL.md not found')) {
320+
throw new Error(`SKILL.md not found in ${skillPath}`);
321+
}
322+
throw err;
224323
}
225324

226-
const parsed = matter(skillMdFile.content as string);
227-
const frontmatter = parsed.data;
228-
const instructions = parsed.content.trim();
229-
230-
// 4. Discover references/, scripts/, assets/ subdirectories for the path arrays
231-
const allPaths = files.map(f => f.path);
232-
const references = collectSubdirPaths(allPaths, 'references');
233-
const scripts = collectSubdirPaths(allPaths, 'scripts');
234-
const assets = collectSubdirPaths(allPaths, 'assets');
235-
236-
// 5. Build snapshot
237-
const snapshot: Omit<StorageSkillSnapshotType, 'tree'> = {
238-
name: frontmatter.name,
239-
description: frontmatter.description,
240-
instructions,
241-
license: frontmatter.license,
242-
compatibility: frontmatter.compatibility,
243-
metadata: frontmatter.metadata,
244-
...(references.length > 0 ? { references } : {}),
245-
...(scripts.length > 0 ? { scripts } : {}),
246-
...(assets.length > 0 ? { assets } : {}),
247-
};
248-
249-
return { snapshot, tree, blobs };
325+
return { snapshot, tree, blobs, files: fileNodes };
250326
}
251327

252328
/**

packages/editor/src/namespaces/base.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,11 @@ export abstract class CrudEditorNamespace<
180180
*/
181181
clearCache(id?: string): void {
182182
if (id) {
183-
const wasCached = this._cache.has(id);
184183
this._cache.delete(id);
185-
if (wasCached) {
186-
this.onCacheEvict(id);
187-
}
184+
// Always notify subclasses so they can clean up runtime registries
185+
// (e.g. remove the agent from mastra.#agents), even if the entity
186+
// wasn't in the editor cache (version-specific lookups skip caching).
187+
this.onCacheEvict(id);
188188
this.logger?.debug(`[clearCache] Cleared cache for "${id}"`);
189189
} else {
190190
for (const cachedId of Array.from(this._cache.keys())) {

packages/editor/src/namespaces/skill.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,23 @@ export class EditorSkillNamespace extends CrudEditorNamespace<
6868
throw new Error('No blob store is configured. Register one via new MastraEditor({ blobStores: [...] })');
6969

7070
// Collect and store blobs
71-
const { snapshot, tree } = await publishSkillFromSource(source, skillPath, blobStore);
71+
const { snapshot, tree, files } = await publishSkillFromSource(source, skillPath, blobStore);
7272

73-
// Update the skill with new version data + tree (creates a new version)
73+
// Strip undefined keys before passing to update(); see the matching
74+
// comment in the HTTP publish handler. Adapters that bind args raw
75+
// (libsql, pg) reject undefined as an argument.
76+
const snapshotUpdate: Record<string, unknown> = {};
77+
for (const [key, value] of Object.entries(snapshot)) {
78+
if (value !== undefined) snapshotUpdate[key] = value;
79+
}
80+
81+
// Update the skill with new version data + tree + UI-facing file tree
82+
// (creates a new version)
7483
await skillStore.update({
7584
id: skillId,
76-
...snapshot,
85+
...snapshotUpdate,
7786
tree,
87+
files,
7888
status: 'published',
7989
});
8090

packages/editor/src/namespaces/workspace.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Workspace } from '@mastra/core/workspace';
1+
import { Workspace, CompositeFilesystem } from '@mastra/core/workspace';
22
import type { WorkspaceFilesystem, WorkspaceSandbox, SkillSource } from '@mastra/core/workspace';
33
import type { WorkspaceConfig } from '@mastra/core/workspace';
44
import type {
@@ -9,6 +9,7 @@ import type {
99
StorageResolvedWorkspaceType,
1010
StorageListWorkspacesResolvedOutput,
1111
StorageWorkspaceSnapshotType,
12+
StorageWorkspaceToolsConfig,
1213
StorageFilesystemConfig,
1314
StorageSandboxConfig,
1415
} from '@mastra/core/storage';
@@ -143,6 +144,71 @@ export class EditorWorkspaceNamespace extends CrudEditorNamespace<
143144
return new Workspace(config);
144145
}
145146

147+
/**
148+
* Serialize a runtime Workspace instance into a StorageWorkspaceSnapshotType.
149+
* The reverse of hydrateSnapshotToWorkspace — extracts provider IDs and config
150+
* from live filesystem/sandbox instances so the workspace can be persisted to the DB.
151+
*/
152+
async snapshotFromWorkspace(workspace: Workspace): Promise<StorageWorkspaceSnapshotType> {
153+
const snapshot: StorageWorkspaceSnapshotType = {
154+
name: workspace.name,
155+
};
156+
157+
const fs = workspace.filesystem;
158+
if (fs) {
159+
if (fs instanceof CompositeFilesystem) {
160+
// Workspace uses mounts — serialize each mounted filesystem
161+
const mounts: Record<string, StorageFilesystemConfig> = {};
162+
for (const [mountPath, mountedFs] of fs.mounts) {
163+
mounts[mountPath] = await this.serializeFilesystem(mountedFs);
164+
}
165+
snapshot.mounts = mounts;
166+
} else {
167+
// Single filesystem
168+
snapshot.filesystem = await this.serializeFilesystem(fs);
169+
}
170+
}
171+
172+
const sandbox = workspace.sandbox;
173+
if (sandbox) {
174+
// Sandbox.getInfo() is async and returns metadata that we round-trip through the
175+
// stored config so resolveSandbox() can re-instantiate the provider with its
176+
// original constructor configuration.
177+
const info = typeof sandbox.getInfo === 'function' ? await sandbox.getInfo() : undefined;
178+
snapshot.sandbox = {
179+
provider: sandbox.provider,
180+
config: info?.metadata ?? {},
181+
};
182+
}
183+
184+
const tools = workspace.getToolsConfig();
185+
if (tools) {
186+
// Only serialize static boolean values — runtime functions can't be stored
187+
const storageTools: StorageWorkspaceToolsConfig = {};
188+
if (typeof tools.enabled === 'boolean') storageTools.enabled = tools.enabled;
189+
if (typeof tools.requireApproval === 'boolean') storageTools.requireApproval = tools.requireApproval;
190+
if (Object.keys(storageTools).length > 0) {
191+
snapshot.tools = storageTools;
192+
}
193+
}
194+
195+
return snapshot;
196+
}
197+
198+
/**
199+
* Serialize a runtime WorkspaceFilesystem into a StorageFilesystemConfig.
200+
* Awaits getInfo() so async providers like CompositeFilesystem keep their mount metadata.
201+
*/
202+
private async serializeFilesystem(fs: WorkspaceFilesystem): Promise<StorageFilesystemConfig> {
203+
const info = typeof fs.getInfo === 'function' ? await fs.getInfo() : undefined;
204+
const metadata = info && typeof info === 'object' && 'metadata' in info ? (info as any).metadata : undefined;
205+
return {
206+
provider: fs.provider,
207+
config: metadata ?? {},
208+
readOnly: fs.readOnly,
209+
};
210+
}
211+
146212
/**
147213
* Resolve a stored filesystem config to a runtime WorkspaceFilesystem instance.
148214
* Looks up the provider by ID in the editor's registry (which includes built-in providers).

0 commit comments

Comments
 (0)