mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
sandbox: add CompositeBackend with prefix-based routing
Routes paths to sub-backends by longest matching prefix and translates paths/FilesUpdate keys across the boundary. Wires SANDBOX_TYPE=composite into the factory and includes it under hasStatefulSmartAgent so state persistence still applies. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5cba8029c4
commit
68e88d8a2c
+402
@@ -0,0 +1,402 @@
|
||||
import { CompositeBackend } from './CompositeBackend'
|
||||
import { StateBackend } from './StateBackend'
|
||||
import { BackendProtocol } from '../BackendProtocol'
|
||||
|
||||
describe('CompositeBackend constructor', () => {
|
||||
it('rejects route prefix of "/"', () => {
|
||||
expect(() => new CompositeBackend(new StateBackend(), { '/': new StateBackend() })).toThrow()
|
||||
})
|
||||
|
||||
it('rejects empty-string route prefix', () => {
|
||||
expect(() => new CompositeBackend(new StateBackend(), { '': new StateBackend() })).toThrow()
|
||||
})
|
||||
|
||||
it('normalizes route prefixes to end with trailing slash', async () => {
|
||||
// Register without trailing slash; path under that mount should still route to the mount.
|
||||
const inner = new StateBackend({ '/x.md': { content: 'hi', mimeType: 'text/markdown', created_at: 0, modified_at: 0 } })
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a': inner })
|
||||
const result = await composite.ls('/a/')
|
||||
expect('files' in result && result.files.map((f) => f.name)).toEqual(['x.md'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.resolve (via ls)', () => {
|
||||
it('routes to default when no routes registered', async () => {
|
||||
const def = new StateBackend({
|
||||
'/x.md': { content: 'hi', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, {})
|
||||
const result = await composite.ls('/')
|
||||
expect('files' in result && result.files.map((f) => f.name)).toEqual(['x.md'])
|
||||
})
|
||||
|
||||
it('routes path under a single registered prefix to that mount', async () => {
|
||||
const def = new StateBackend()
|
||||
const inner = new StateBackend({
|
||||
'/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, { '/a/': inner })
|
||||
const result = await composite.ls('/a/')
|
||||
expect('files' in result && result.files.map((f) => f.name)).toEqual(['inside.md'])
|
||||
})
|
||||
|
||||
it('selects the longest matching prefix when nested routes match', async () => {
|
||||
const def = new StateBackend()
|
||||
const outer = new StateBackend({
|
||||
'/wrong.md': { content: 'wrong', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const inner = new StateBackend({
|
||||
'/right.md': { content: 'right', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, { '/a/': outer, '/a/b/': inner })
|
||||
const result = await composite.ls('/a/b/')
|
||||
expect('files' in result && result.files.map((f) => f.name)).toEqual(['right.md'])
|
||||
})
|
||||
|
||||
it('does not match when prefix would cross a non-slash boundary', async () => {
|
||||
// Route '/foo/' should NOT match '/foobar/x.md' — slash boundary is required.
|
||||
const def = new StateBackend({
|
||||
'/foobar/x.md': { content: 'def', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'inner', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, { '/foo/': inner })
|
||||
const result = await composite.ls('/foobar/')
|
||||
expect('files' in result && result.files.map((f) => f.name)).toEqual(['x.md'])
|
||||
})
|
||||
|
||||
it('matches a bare path equal to the prefix without trailing slash', async () => {
|
||||
// ls('/a') should still route to the '/a/' mount.
|
||||
const def = new StateBackend()
|
||||
const inner = new StateBackend({
|
||||
'/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, { '/a/': inner })
|
||||
const result = await composite.ls('/a')
|
||||
expect('files' in result && result.files.map((f) => f.name)).toEqual(['inside.md'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.read', () => {
|
||||
it('passes path through unchanged when routing to default', async () => {
|
||||
const def = new StateBackend({
|
||||
'/x.md': { content: 'hello', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const spy = jest.spyOn(def, 'read')
|
||||
const composite = new CompositeBackend(def, {})
|
||||
const result = await composite.read('/x.md')
|
||||
expect(spy).toHaveBeenCalledWith('/x.md', undefined, undefined)
|
||||
expect('content' in result && result.content).toBe('hello')
|
||||
})
|
||||
|
||||
it('strips the matched prefix when routing to a mount', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/inside.md': { content: 'mounted', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const spy = jest.spyOn(inner, 'read')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.read('/a/inside.md')
|
||||
expect(spy).toHaveBeenCalledWith('/inside.md', undefined, undefined)
|
||||
expect('content' in result && result.content).toBe('mounted')
|
||||
})
|
||||
|
||||
it('forwards offset and limit to the sub-backend', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'a\nb\nc\nd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const spy = jest.spyOn(inner, 'read')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
await composite.read('/a/x.md', 1, 2)
|
||||
expect(spy).toHaveBeenCalledWith('/x.md', 1, 2)
|
||||
})
|
||||
|
||||
it('passes error results through unchanged', async () => {
|
||||
const composite = new CompositeBackend(new StateBackend(), {})
|
||||
const result = await composite.read('/missing.md')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('/missing.md') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.readRaw', () => {
|
||||
it('strips the matched prefix when routing to a mount', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'm', mimeType: 'text/markdown', created_at: 5, modified_at: 6 }
|
||||
})
|
||||
const spy = jest.spyOn(inner, 'readRaw')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.readRaw('/a/x.md')
|
||||
expect(spy).toHaveBeenCalledWith('/x.md')
|
||||
expect('data' in result && result.data.content).toBe('m')
|
||||
})
|
||||
|
||||
it('passes path through unchanged when routing to default', async () => {
|
||||
const def = new StateBackend({
|
||||
'/x.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const spy = jest.spyOn(def, 'readRaw')
|
||||
const composite = new CompositeBackend(def, {})
|
||||
await composite.readRaw('/x.md')
|
||||
expect(spy).toHaveBeenCalledWith('/x.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.write', () => {
|
||||
it('writes to default and returns the original virtual path', async () => {
|
||||
const def = new StateBackend()
|
||||
const composite = new CompositeBackend(def, {})
|
||||
const result = await composite.write('/x.md', 'hi')
|
||||
expect('path' in result && result.path).toBe('/x.md')
|
||||
// FilesUpdate keys for default come back unchanged
|
||||
expect('filesUpdate' in result && result.filesUpdate).toMatchObject({ '/x.md': expect.any(Object) })
|
||||
})
|
||||
|
||||
it('strips prefix on dispatch and re-prepends prefix on result.path', async () => {
|
||||
const inner = new StateBackend()
|
||||
const writeSpy = jest.spyOn(inner, 'write')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.write('/a/x.md', 'hi')
|
||||
expect(writeSpy).toHaveBeenCalledWith('/x.md', 'hi')
|
||||
expect('path' in result && result.path).toBe('/a/x.md')
|
||||
})
|
||||
|
||||
it('re-keys FilesUpdate entries with the mount prefix when sub-backend returns a state-shaped update', async () => {
|
||||
const inner = new StateBackend()
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.write('/a/x.md', 'hi')
|
||||
expect('filesUpdate' in result).toBe(true)
|
||||
if ('filesUpdate' in result && result.filesUpdate) {
|
||||
const keys = Object.keys(result.filesUpdate)
|
||||
expect(keys).toEqual(['/a/x.md'])
|
||||
expect(result.filesUpdate['/a/x.md']).toMatchObject({ content: 'hi' })
|
||||
}
|
||||
})
|
||||
|
||||
it('passes filesUpdate through as null for local-style backends', async () => {
|
||||
// Stub backend that mimics LocalBackend's null filesUpdate.
|
||||
const inner: BackendProtocol = {
|
||||
write: async (p) => ({ path: p, filesUpdate: null }),
|
||||
read: async () => ({ error: 'unused' }),
|
||||
readRaw: async () => ({ error: 'unused' }),
|
||||
edit: async () => ({ error: 'unused' }),
|
||||
ls: async () => ({ files: [] }),
|
||||
glob: async () => ({ files: [], truncated: false }),
|
||||
grep: async () => ({ matches: [], truncated: false })
|
||||
}
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.write('/a/x.md', 'hi')
|
||||
expect('path' in result && result.path).toBe('/a/x.md')
|
||||
expect('filesUpdate' in result && result.filesUpdate).toBeNull()
|
||||
})
|
||||
|
||||
it('passes error results through unchanged', async () => {
|
||||
const def = new StateBackend({
|
||||
'/x.md': { content: 'existing', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, {})
|
||||
const result = await composite.write('/x.md', 'second')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.edit', () => {
|
||||
it('strips prefix on dispatch and re-prepends prefix on result.path', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'hello world', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const editSpy = jest.spyOn(inner, 'edit')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.edit('/a/x.md', 'world', 'there')
|
||||
expect(editSpy).toHaveBeenCalledWith('/x.md', 'world', 'there', false)
|
||||
expect('path' in result && result.path).toBe('/a/x.md')
|
||||
expect('occurrences' in result && result.occurrences).toBe(1)
|
||||
})
|
||||
|
||||
it('re-keys FilesUpdate entries with the mount prefix', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'hello world', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.edit('/a/x.md', 'world', 'there')
|
||||
if ('filesUpdate' in result && result.filesUpdate) {
|
||||
expect(Object.keys(result.filesUpdate)).toEqual(['/a/x.md'])
|
||||
} else {
|
||||
throw new Error('expected filesUpdate to be populated')
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards replaceAll flag to the sub-backend', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'a a a', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const editSpy = jest.spyOn(inner, 'edit')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
await composite.edit('/a/x.md', 'a', 'b', true)
|
||||
expect(editSpy).toHaveBeenCalledWith('/x.md', 'a', 'b', true)
|
||||
})
|
||||
|
||||
it('passes error results through unchanged', async () => {
|
||||
const def = new StateBackend({
|
||||
'/x.md': { content: 'no match here', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, {})
|
||||
const result = await composite.edit('/x.md', 'absent', 'nope')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.ls path translation', () => {
|
||||
it('re-prepends mount prefix to FileInfo.path entries when listing inside a mount', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.ls('/a/')
|
||||
if (!('files' in result)) throw new Error('expected files')
|
||||
expect(result.files.map((f) => f.path)).toEqual(['/a/inside.md'])
|
||||
})
|
||||
|
||||
it('does NOT synthesize directory entries for registered mounts when listing /', async () => {
|
||||
const def = new StateBackend({
|
||||
'/workspace/foo.md': { content: 'w', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, { '/a/': inner, '/b/': inner })
|
||||
const result = await composite.ls('/')
|
||||
if (!('files' in result)) throw new Error('expected files')
|
||||
// Only the default backend's root entries — no /a/ or /b/ synthesized.
|
||||
expect(result.files.map((f) => f.name).sort()).toEqual(['workspace'])
|
||||
})
|
||||
|
||||
it('passes default-backend ls results through unchanged', async () => {
|
||||
const def = new StateBackend({
|
||||
'/foo/x.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, {})
|
||||
const result = await composite.ls('/foo/')
|
||||
if (!('files' in result)) throw new Error('expected files')
|
||||
expect(result.files.map((f) => f.path)).toEqual(['/foo/x.md'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.glob', () => {
|
||||
it('routes to mount on basePath match and re-prepends prefix to result paths', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/x.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 },
|
||||
'/y.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const globSpy = jest.spyOn(inner, 'glob')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.glob('*.md', '/a/')
|
||||
expect(globSpy).toHaveBeenCalledWith('*.md', '/')
|
||||
if (!('files' in result)) throw new Error('expected files')
|
||||
expect(result.files.map((f) => f.path).sort()).toEqual(['/a/x.md', '/a/y.md'])
|
||||
})
|
||||
|
||||
it('passes default-backend glob through unchanged', async () => {
|
||||
const def = new StateBackend({
|
||||
'/foo.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, {})
|
||||
const result = await composite.glob('*.md', '/')
|
||||
if (!('files' in result)) throw new Error('expected files')
|
||||
expect(result.files.map((f) => f.path)).toEqual(['/foo.md'])
|
||||
})
|
||||
|
||||
it('does NOT cross mount boundaries when globbing from /', async () => {
|
||||
const def = new StateBackend({
|
||||
'/default.md': { content: 'd', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const inner = new StateBackend({
|
||||
'/inside.md': { content: 'm', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, { '/a/': inner })
|
||||
const result = await composite.glob('**/*.md', '/')
|
||||
if (!('files' in result)) throw new Error('expected files')
|
||||
// Only default's match; the /a/ mount's inside.md is invisible.
|
||||
expect(result.files.map((f) => f.path)).toEqual(['/default.md'])
|
||||
})
|
||||
|
||||
it('preserves the truncated flag from the sub-backend', async () => {
|
||||
const inner: BackendProtocol = {
|
||||
read: async () => ({ error: 'unused' }),
|
||||
readRaw: async () => ({ error: 'unused' }),
|
||||
write: async () => ({ error: 'unused' }),
|
||||
edit: async () => ({ error: 'unused' }),
|
||||
ls: async () => ({ files: [] }),
|
||||
glob: async () => ({ files: [], truncated: true }),
|
||||
grep: async () => ({ matches: [], truncated: false })
|
||||
}
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.glob('*', '/a/')
|
||||
expect('truncated' in result && result.truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes error results through unchanged', async () => {
|
||||
const inner: BackendProtocol = {
|
||||
read: async () => ({ error: 'unused' }),
|
||||
readRaw: async () => ({ error: 'unused' }),
|
||||
write: async () => ({ error: 'unused' }),
|
||||
edit: async () => ({ error: 'unused' }),
|
||||
ls: async () => ({ files: [] }),
|
||||
glob: async () => ({ error: 'boom' }),
|
||||
grep: async () => ({ matches: [], truncated: false })
|
||||
}
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.glob('*', '/a/')
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompositeBackend.grep', () => {
|
||||
it('routes to mount on dirPath match and re-prepends prefix to match paths', async () => {
|
||||
const inner = new StateBackend({
|
||||
'/notes.md': { content: 'first line\nfind me\nthird line', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const grepSpy = jest.spyOn(inner, 'grep')
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
const result = await composite.grep('find', '/a/', null)
|
||||
expect(grepSpy).toHaveBeenCalledWith('find', '/', null)
|
||||
if (!('matches' in result)) throw new Error('expected matches')
|
||||
expect(result.matches.map((m) => m.path)).toEqual(['/a/notes.md'])
|
||||
})
|
||||
|
||||
it('forwards the glob filter argument to the sub-backend', async () => {
|
||||
const inner = new StateBackend()
|
||||
const grepSpy = jest.spyOn(inner, 'grep').mockResolvedValue({ matches: [], truncated: false })
|
||||
const composite = new CompositeBackend(new StateBackend(), { '/a/': inner })
|
||||
await composite.grep('foo', '/a/', '*.md')
|
||||
expect(grepSpy).toHaveBeenCalledWith('foo', '/', '*.md')
|
||||
})
|
||||
|
||||
it('does NOT cross mount boundaries when grepping from /', async () => {
|
||||
const def = new StateBackend({
|
||||
'/default.md': { content: 'find me', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const inner = new StateBackend({
|
||||
'/mounted.md': { content: 'find me', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const composite = new CompositeBackend(def, { '/a/': inner })
|
||||
const result = await composite.grep('find', '/', null)
|
||||
if (!('matches' in result)) throw new Error('expected matches')
|
||||
expect(result.matches.map((m) => m.path)).toEqual(['/default.md'])
|
||||
})
|
||||
|
||||
it('routes to default when dirPath is null', async () => {
|
||||
const def = new StateBackend({
|
||||
'/x.md': { content: 'find me', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
})
|
||||
const grepSpy = jest.spyOn(def, 'grep')
|
||||
const composite = new CompositeBackend(def, { '/a/': new StateBackend() })
|
||||
await composite.grep('find', null, null)
|
||||
expect(grepSpy).toHaveBeenCalledWith('find', '/', null)
|
||||
})
|
||||
|
||||
it('passes error results through unchanged', async () => {
|
||||
const composite = new CompositeBackend(new StateBackend(), {})
|
||||
const result = await composite.grep('[invalid', '/', null)
|
||||
expect('error' in result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
BackendProtocol,
|
||||
EditResult,
|
||||
FilesUpdate,
|
||||
GlobResult,
|
||||
GrepResult,
|
||||
LsResult,
|
||||
ReadRawResult,
|
||||
ReadResult,
|
||||
WriteResult
|
||||
} from '../BackendProtocol'
|
||||
|
||||
interface Route {
|
||||
prefix: string
|
||||
backend: BackendProtocol
|
||||
}
|
||||
|
||||
export class CompositeBackend implements BackendProtocol {
|
||||
private default_: BackendProtocol
|
||||
private routes: Route[]
|
||||
|
||||
constructor(default_: BackendProtocol, routes: Record<string, BackendProtocol> = {}) {
|
||||
this.default_ = default_
|
||||
this.routes = Object.entries(routes)
|
||||
.map(([rawPrefix, backend]) => {
|
||||
if (rawPrefix === '' || rawPrefix === '/') {
|
||||
throw new Error(`Invalid route prefix: ${JSON.stringify(rawPrefix)}`)
|
||||
}
|
||||
const prefix = rawPrefix.endsWith('/') ? rawPrefix : rawPrefix + '/'
|
||||
return { prefix, backend }
|
||||
})
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length)
|
||||
}
|
||||
|
||||
private resolve(filePath: string): { backend: BackendProtocol; prefix: string } {
|
||||
for (const r of this.routes) {
|
||||
if (filePath.startsWith(r.prefix) || filePath === r.prefix.slice(0, -1)) {
|
||||
return { backend: r.backend, prefix: r.prefix }
|
||||
}
|
||||
}
|
||||
return { backend: this.default_, prefix: '' }
|
||||
}
|
||||
|
||||
private translateIn(filePath: string, prefix: string): string {
|
||||
if (!prefix) return filePath
|
||||
return '/' + filePath.slice(prefix.length)
|
||||
}
|
||||
|
||||
private translateOut(subPath: string, prefix: string): string {
|
||||
if (!prefix) return subPath
|
||||
return prefix + subPath.slice(1)
|
||||
}
|
||||
|
||||
private rekeyFilesUpdate(update: FilesUpdate | null, prefix: string): FilesUpdate | null {
|
||||
if (update === null || !prefix) return update
|
||||
const rekeyed: FilesUpdate = {}
|
||||
for (const [key, value] of Object.entries(update)) {
|
||||
rekeyed[this.translateOut(key, prefix)] = value
|
||||
}
|
||||
return rekeyed
|
||||
}
|
||||
|
||||
async ls(dirPath: string): Promise<LsResult> {
|
||||
const { backend, prefix } = this.resolve(dirPath)
|
||||
const result = await backend.ls(this.translateIn(dirPath, prefix))
|
||||
if ('error' in result) return result
|
||||
return {
|
||||
files: result.files.map((f) => ({ ...f, path: this.translateOut(f.path, prefix) }))
|
||||
}
|
||||
}
|
||||
|
||||
async read(filePath: string, offset?: number, limit?: number): Promise<ReadResult> {
|
||||
const { backend, prefix } = this.resolve(filePath)
|
||||
return backend.read(this.translateIn(filePath, prefix), offset, limit)
|
||||
}
|
||||
|
||||
async readRaw(filePath: string): Promise<ReadRawResult> {
|
||||
const { backend, prefix } = this.resolve(filePath)
|
||||
return backend.readRaw(this.translateIn(filePath, prefix))
|
||||
}
|
||||
|
||||
async write(filePath: string, content: string | Uint8Array): Promise<WriteResult> {
|
||||
const { backend, prefix } = this.resolve(filePath)
|
||||
const result = await backend.write(this.translateIn(filePath, prefix), content)
|
||||
if ('error' in result) return result
|
||||
return {
|
||||
path: this.translateOut(result.path, prefix),
|
||||
filesUpdate: this.rekeyFilesUpdate(result.filesUpdate, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
async edit(filePath: string, oldStr: string, newStr: string, replaceAll = false): Promise<EditResult> {
|
||||
const { backend, prefix } = this.resolve(filePath)
|
||||
const result = await backend.edit(this.translateIn(filePath, prefix), oldStr, newStr, replaceAll)
|
||||
if ('error' in result) return result
|
||||
return {
|
||||
path: this.translateOut(result.path, prefix),
|
||||
occurrences: result.occurrences,
|
||||
filesUpdate: this.rekeyFilesUpdate(result.filesUpdate, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
async glob(pattern: string, basePath = '/'): Promise<GlobResult> {
|
||||
const { backend, prefix } = this.resolve(basePath)
|
||||
const result = await backend.glob(pattern, this.translateIn(basePath, prefix))
|
||||
if ('error' in result) return result
|
||||
return {
|
||||
files: result.files.map((f) => ({ ...f, path: this.translateOut(f.path, prefix) })),
|
||||
truncated: result.truncated
|
||||
}
|
||||
}
|
||||
|
||||
async grep(pattern: string, dirPath: string | null = '/', glob: string | null = null): Promise<GrepResult> {
|
||||
const basePath = dirPath ?? '/'
|
||||
const { backend, prefix } = this.resolve(basePath)
|
||||
const result = await backend.grep(pattern, this.translateIn(basePath, prefix), glob)
|
||||
if ('error' in result) return result
|
||||
return {
|
||||
matches: result.matches.map((m) => ({ ...m, path: this.translateOut(m.path, prefix) })),
|
||||
truncated: result.truncated
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,3 +203,30 @@ describe('createBackend — isolation', () => {
|
||||
if ('content' in readBack) expect(readBack.content).toBe('persisted across messages')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBackend with SANDBOX_TYPE=composite', () => {
|
||||
afterEach(() => {
|
||||
// Reset to unset so we don't leak SANDBOX_TYPE into other suites.
|
||||
// Avoid `process.env.SANDBOX_TYPE = undefined` — that stores the literal string "undefined".
|
||||
delete process.env.SANDBOX_TYPE
|
||||
})
|
||||
|
||||
it('returns a working backend with empty routes (default behaves like StateBackend)', async () => {
|
||||
process.env.SANDBOX_TYPE = 'composite'
|
||||
const backend = await createBackend()
|
||||
const writeResult = await backend.write('/workspace/notes.md', 'hello')
|
||||
expect('path' in writeResult && writeResult.path).toBe('/workspace/notes.md')
|
||||
const readResult = await backend.read('/workspace/notes.md')
|
||||
expect('content' in readResult && readResult.content).toBe('hello')
|
||||
})
|
||||
|
||||
it('seeds the default StateBackend from initialFiles', async () => {
|
||||
process.env.SANDBOX_TYPE = 'composite'
|
||||
const initial = {
|
||||
'/workspace/seed.md': { content: 'seeded', mimeType: 'text/markdown', created_at: 0, modified_at: 0 }
|
||||
}
|
||||
const backend = await createBackend(initial)
|
||||
const result = await backend.read('/workspace/seed.md')
|
||||
expect('content' in result && result.content).toBe('seeded')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { BackendProtocol, FileData } from './BackendProtocol'
|
||||
import { CompositeBackend } from './backends/CompositeBackend'
|
||||
import { LocalBackend } from './backends/LocalBackend'
|
||||
import { StateBackend } from './backends/StateBackend'
|
||||
|
||||
@@ -37,6 +38,8 @@ export async function createBackend(
|
||||
const root = path.join(base, ...buildScopeSegments(scope))
|
||||
return new LocalBackend(root)
|
||||
}
|
||||
case 'composite':
|
||||
return new CompositeBackend(new StateBackend(initialFiles), {})
|
||||
default:
|
||||
throw new Error(`Unknown SANDBOX_TYPE: ${type}`)
|
||||
}
|
||||
|
||||
@@ -1620,8 +1620,12 @@ export const executeAgentFlow = async ({
|
||||
// If the state is persistent, get the state from the previous execution.
|
||||
// SmartAgent with SANDBOX_TYPE=state always requires state persistence for its sandbox filesystem.
|
||||
const startPersistState = nodes.find((node) => node.data.name === 'startAgentflow')?.data.inputs?.startPersistState
|
||||
const sandboxType = process.env.SANDBOX_TYPE || 'state'
|
||||
|
||||
// NOTE / TODO: currently both sandboxType state and composite needed here so that both are able to have
|
||||
// persistence on executionData state. But we might remove composite later depending on the usage.
|
||||
const hasStatefulSmartAgent =
|
||||
nodes.some((node) => node.data.name === 'smartAgentAgentflow') && (process.env.SANDBOX_TYPE || 'state') === 'state'
|
||||
nodes.some((node) => node.data.name === 'smartAgentAgentflow') && (sandboxType === 'state' || sandboxType === 'composite')
|
||||
if ((startPersistState === true || hasStatefulSmartAgent) && previousExecution) {
|
||||
const previousExecutionData = (JSON.parse(previousExecution.executionData) as IAgentflowExecutedData[]) ?? []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user