Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { resolveImageUrl, MAX_INLINE_IMAGE_BYTES } from './image-inlining.js';
import { escapeXmlAttr, escapeXmlText } from './node-element.js';
import { isRasterizableImageMime } from '../../../../utils/mime.js';
import { ARTIFACT_URL_REGEX } from '../../../artifact/utils.js';
import { canvasBlobs } from '../../../storage/index.js';
import { space } from '../../../storage/index.js';

import type { AgentInputPart } from '@agenetes/protocol';
import type { ChatAttachment } from '@huabu/shared';
Expand Down Expand Up @@ -227,7 +227,7 @@ export async function buildAttachmentParts(
if (resolvedCanvasId && resolvedFilename) {
try {
const bytes =
await canvasBlobs(resolvedCanvasId).read(resolvedFilename);
await space(resolvedCanvasId).blobs.read(resolvedFilename);
// Attachments are inlined as text; binary bytes simply
// decode to mojibake and the URL-only branch is used instead.
if (bytes) fileContent = bytes.toString('utf-8');
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/modules/agent/memory/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { existsSync } from 'node:fs';

import { atomicWriteJson, mkdirp, readJson } from '../../../utils/fs.js';
import { createKeyedMutex } from '../../../utils/keyed-mutex.js';
import { spaceDirectory } from '../../storage/index.js';
import { space } from '../../storage/index.js';
import { memoryStatePath, canvasMemoryDir } from '../../workspace/paths.js';

/** Op-count threshold that triggers a memory analysis pass. */
Expand Down Expand Up @@ -83,7 +83,12 @@ export function writeMemoryState(canvasId: string, state: MemoryState): void {
// file. Same hazard for any in-flight memory worker that calls
// `markAnalyzed` post-delete. Skip the write when the canvas root
// is gone; losing one bookkeeping write is harmless.
if (!existsSync(spaceDirectory(canvasId))) return;
// Disk-only by construction: the hazard is an ad-hoc file write
// recreating a directory the delete removed, and a backend with no
// directory has no such hazard. Phase 4.6 retires the guard entirely when
// this state moves onto the extension substrate (proposal §12.6.3).
const tree = space(canvasId).diskTree;
if (tree && !existsSync(tree.directory())) return;
mkdirp(canvasMemoryDir(canvasId));
atomicWriteJson(memoryStatePath(canvasId), state);
}
Expand Down
28 changes: 26 additions & 2 deletions apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { readFileSync, readdirSync, statSync, type Dirent } from 'node:fs';
import path from 'node:path';

import { parseFrontmatter } from '../../../../utils/markdown-frontmatter.js';
import { getCanvasStore, spaceDirectory } from '../../../storage/index.js';
import { getCanvasStore, space } from '../../../storage/index.js';

// ─── Always-skipped directory names ─────────────────────────────────────────

Expand Down Expand Up @@ -143,7 +143,18 @@ export function safeResolve(canvasId: string, rel: string): string {
) {
throw new Error(`Invalid canvasId: ${canvasId}`);
}
const root = spaceDirectory(canvasId);
// The built-in file tools are Disk-only and stated as such (proposal
// §6.4.3, disposition A): off Disk the first-party agent reaches a Space
// over RFS/HTTP, which is what external agents already use. Refusing here
// is the backstop behind the capability matrix, not the primary check.
const tree = space(canvasId).diskTree;
if (!tree) {
throw new Error(
'Built-in file tools need a Space directory, which the active ' +
'structured backend does not provide.',
);
}
const root = tree.directory();
// Accept the clean virtual prefixes (`upload/`, `artifacts/`) as aliases
// for their hidden on-disk dirs so agents can reference either form.
const target = path.resolve(root, toPhysicalRel(rel));
Expand All @@ -155,6 +166,19 @@ export function safeResolve(canvasId: string, rel: string): string {
return target;
}

/**
* The sandbox root for one Space.
*
* Exported because classifying a resolved path as "inside this Space" is a
* question about the sandbox, not about storage: the caller that asks is
* already working in sandbox coordinates, and routing it through storage
* would make it a consumer of a backend capability it has no stake in
* (proposal §6.4.3).
*/
export function sandboxRoot(canvasId: string): string {
return safeResolve(canvasId, '');
}

/** Normalise a relative path to forward slashes. */
export function normalizeRel(rel: string): string {
return rel.split(path.sep).join('/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
} from '@huabu/shared';

import { getLogger } from '../../../../utils/logger.js';
import { canvasBlobs } from '../../../storage/index.js';
import { space } from '../../../storage/index.js';
import { getAzureImageConfig } from '../../llm.js';

import type { generateImageParamsSchema } from '../definitions.js';
Expand Down Expand Up @@ -131,7 +131,7 @@ export async function handleGenerateImage(
// ── Load reference artifacts upfront ──────────────────────────────────
// Any missing/invalid ref is an early hard error — better than sending
// a partial set to Azure and getting cryptic results.
const blobs = canvasBlobs(args.canvasId);
const blobs = space(args.canvasId).blobs;
const refImages: Array<{ key: string; bytes: Buffer }> = [];
for (const key of refs) {
if (typeof key !== 'string' || !key.trim()) {
Expand Down
16 changes: 8 additions & 8 deletions apps/server/src/modules/artifact/artifact.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import artifactRoute from './artifact.route.js';
import { createCanvas, deleteCanvas } from '../storage/compatibility/canvas.js';
import {
canvasBlobs,
space,
getStorage,
resetStorageCache,
setStorageForTesting,
Expand Down Expand Up @@ -194,7 +194,7 @@ describe('artifact route', () => {
});

expect(upload.statusCode).toBe(500);
expect(await canvasBlobs('missing').list()).toEqual([]);
expect(await space('missing').blobs.list()).toEqual([]);
await app.close();
});

Expand Down Expand Up @@ -234,7 +234,7 @@ describe('artifact route', () => {
const upload = await uploading;
expect(upload.statusCode).toBe(500);
expect(blocker.putCalls()).toBe(0);
expect(await canvasBlobs('c1').list()).toEqual([]);
expect(await space('c1').blobs.list()).toEqual([]);
} finally {
blocker.releaseDelete();
blocker.restore();
Expand All @@ -244,7 +244,7 @@ describe('artifact route', () => {

it('serves a byte range so media nodes can seek', async () => {
const app = await buildApp();
await canvasBlobs('c1').put('a.png', png);
await space('c1').blobs.put('a.png', png);

const res = await app.inject({
method: 'GET',
Expand All @@ -260,7 +260,7 @@ describe('artifact route', () => {

it('answers 304 for an unchanged artifact', async () => {
const app = await buildApp();
await canvasBlobs('c1').put('a.png', png);
await space('c1').blobs.put('a.png', png);

const first = await app.inject({
method: 'GET',
Expand Down Expand Up @@ -332,7 +332,7 @@ describe('artifact route', () => {

it('clones an artifact into another canvas under a fresh key', async () => {
const app = await buildApp();
await canvasBlobs('src-canvas').put('a.png', png);
await space('src-canvas').blobs.put('a.png', png);

const res = await app.inject({
method: 'POST',
Expand All @@ -346,8 +346,8 @@ describe('artifact route', () => {
expect(uri).toMatch(/\.png$/);

// Destination owns its own copy; the source is untouched.
expect(await canvasBlobs('dst-canvas').read(uri)).toEqual(png);
expect(await canvasBlobs('src-canvas').read('a.png')).toEqual(png);
expect(await space('dst-canvas').blobs.read(uri)).toEqual(png);
expect(await space('src-canvas').blobs.read('a.png')).toEqual(png);

await app.close();
});
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/modules/artifact/artifact.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { type FastifyPluginAsync } from 'fastify';
import { cloneArtifactBodySchema, createId } from '@huabu/shared';

import { sendBlob } from './send-blob.js';
import { canvasBlobs } from '../storage/index.js';
import { space } from '../storage/index.js';
import { extractHtmlFromMhtml, injectBaseHref } from '../web/mhtml.js';

import type {
Expand Down Expand Up @@ -61,7 +61,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {
const name = `${id}${ext}`;

try {
await canvasBlobs(canvasId).put(name, data.file);
await space(canvasId).blobs.put(name, data.file);
} catch (error) {
request.log.error({ err: error }, 'Failed to stream artifact to storage');
return reply.code(500).send({ message: 'Failed to save file' });
Expand All @@ -82,7 +82,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {
'/:canvasId/artifact/:filename',
async (request, reply) => {
const { canvasId, filename } = request.params;
const blobs = canvasBlobs(canvasId);
const blobs = space(canvasId).blobs;
const safeName = path.basename(filename);

// `.mhtml` snapshots are stored as proper multipart/related MHTML
Expand Down Expand Up @@ -151,7 +151,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {

let buffer: Buffer | null;
try {
buffer = await canvasBlobs(srcCanvasId).read(srcKey);
buffer = await space(srcCanvasId).blobs.read(srcKey);
} catch (err) {
request.log.error({ err }, 'Failed to read source artifact for clone');
return reply
Expand All @@ -167,7 +167,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => {
const name = `${id}${ext}`;

try {
await canvasBlobs(dstCanvasId).put(name, buffer);
await space(dstCanvasId).blobs.put(name, buffer);
} catch (err) {
request.log.error({ err }, 'Failed to clone artifact');
return reply
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/modules/artifact/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ARTIFACT_URL_REGEX } from '@huabu/shared';

import { getLogger } from '../../utils/logger.js';
import { IMAGE_MIME_MAP } from '../../utils/mime.js';
import { canvasBlobs } from '../storage/index.js';
import { space } from '../storage/index.js';

const log = getLogger('artifact');

Expand Down Expand Up @@ -56,7 +56,7 @@ export async function resolveArtifactImageUrl(
if (!canvasId || !filename) return url;

try {
const buffer = await canvasBlobs(canvasId).read(filename);
const buffer = await space(canvasId).blobs.read(filename);
if (!buffer) return url;
const ext = path.extname(filename).toLowerCase();
// Never guess `image/png` for an unknown extension: callers forward this
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/modules/canvas/canvas-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { nodeRevisionOf } from '@huabu/shared/canvas-engine';

import { applyDeltasOnServer, executeOnServer } from './canvas-executor.js';
import {
canvasBlobs,
space,
getCanvasStore,
getStructuredStore,
updateNode,
Expand Down Expand Up @@ -273,7 +273,7 @@ describe('executeOnServer — MERGE_NODE_DATA CAS', () => {
src: 'old.svg',
content: '',
});
await canvasBlobs('c1').put(
await space('c1').blobs.put(
'new.svg',
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"></svg>',
Expand Down Expand Up @@ -329,7 +329,7 @@ describe('executeOnServer — MERGE_NODE_DATA CAS', () => {
src: 'pic.svg',
content: '',
});
await canvasBlobs('c1').put(
await space('c1').blobs.put(
'pic.svg',
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"></svg>',
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/modules/canvas/canvas-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import {
} from './world-portal-policy.js';
import { getLogger } from '../../utils/logger.js';
import {
canvasBlobs,
space,
getCanvasStore,
getStructuredStore,
withCanvasMutex,
Expand Down Expand Up @@ -375,7 +375,7 @@ async function aspectHeightForWidth(
width: number,
): Promise<number | null> {
try {
const dim = await readImageDimensions(canvasBlobs(canvasId), src);
const dim = await readImageDimensions(space(canvasId).blobs, src);
if (!dim?.width || !dim?.height || dim.width <= 0 || dim.height <= 0) {
return null;
}
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/modules/canvas/canvas.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import canvasRoutes from './canvas.route.js';
import { withSpaceDirHandlesReleased } from '../storage/backends/disk/space-dir-handles.js';
import { createCanvas, deleteCanvas } from '../storage/compatibility/canvas.js';
import {
canvasBlobs,
space,
getCanvasStore,
getStructuredStore,
resetStorageCache,
Expand Down Expand Up @@ -738,7 +738,7 @@ describe('Space export/import persistence', () => {
change,
]);
const blob = Buffer.from([0, 1, 2, 3, 255]);
await canvasBlobs('c1').put('asset.bin', blob);
await space('c1').blobs.put('asset.bin', blob);

const app = await buildApp();
try {
Expand Down Expand Up @@ -790,7 +790,7 @@ describe('Space export/import persistence', () => {
expect(await importedSpace.changes.read('thread-export')).toEqual(
storedChanges,
);
expect(await canvasBlobs(importedId).read('asset.bin')).toEqual(blob);
expect(await space(importedId).blobs.read('asset.bin')).toEqual(blob);
} finally {
await app.close();
}
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/modules/canvas/canvas.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,11 @@ import {
suggestCanvasDir,
} from '../storage/canvas-dirs.js';
import {
canvasBlobs,
space,
createSpace,
deleteSpace,
getCanvasStore,
getStructuredStore,
spaceDirectory,
type CanvasFile,
type UpdateNodeOutcome,
updateNode,
Expand Down Expand Up @@ -342,7 +341,7 @@ async function singleArtifactProbe(
): Promise<(key: string) => boolean> {
const key = extractArtifactKey(src);
if (!key) return () => false;
const exists = (await canvasBlobs(canvasId).hasMany([key])).has(key);
const exists = (await space(canvasId).blobs.hasMany([key])).has(key);
return (candidate) => candidate === key && exists;
}

Expand Down Expand Up @@ -532,7 +531,7 @@ async function hydrateNodeContent(
const present =
referenced.size === 0
? new Set<string>()
: await canvasBlobs(store.canvasId).hasMany([...referenced]);
: await space(store.canvasId).blobs.hasMany([...referenced]);
const artifactExists = (key: string): boolean => present.has(key);

return nodes.map((node) => {
Expand Down Expand Up @@ -1629,8 +1628,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => {
return reply.code(404).send({ message: 'Canvas not found' });
}

const canvasDir = spaceDirectory(canvasId);
if (!existsSync(canvasDir)) {
// The Space bundle is a Disk projection (proposal §6.4.3, disposition
// A); a portable export generated from records plus reachable blob
// references is a separate later design.
const tree = space(canvasId).diskTree;
const canvasDir = tree?.directory();
if (canvasDir === undefined || !existsSync(canvasDir)) {
return reply.code(404).send({ message: 'Canvas directory not found' });
}

Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/modules/canvas/external.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
takeExternalNote,
} from './external-watcher.js';
import { parseFrontmatter } from '../../utils/markdown-frontmatter.js';
import { spaceDirectory } from '../storage/index.js';
import { space } from '../storage/index.js';

import type { FastifyPluginAsync } from 'fastify';

Expand Down Expand Up @@ -95,7 +95,14 @@ const externalRoutes: FastifyPluginAsync = async (fastify): Promise<void> => {
return reply.code(404).send({ message: 'External note not found' });
}

const abs = path.join(spaceDirectory(canvasId), item.relativePath);
// External-note claim is Disk-only (proposal §6.4.3, disposition A): it
// exists to adopt documents that arrived without going through the
// application, and no database backend has such an arrival path.
const tree = space(canvasId).diskTree;
if (!tree) {
return reply.code(404).send({ message: 'External note not found' });
}
const abs = path.join(tree.directory(), item.relativePath);
let raw: string;
try {
raw = await readFile(abs, 'utf8');
Expand Down
Loading
Loading