Skip to content
Draft
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 @@ -153,6 +153,9 @@ type SubstrateResolution =

/** Where this module keeps one log per thread on a Disk substrate. */
function diskLogPath(substrate: SpaceSubstrate, threadId: string): string {
if (substrate.kind !== 'disk') {
throw new Error('Debug prompt logs require a Disk extension substrate');
}
return path.join(
substrate.directory,
`${sanitizeId(threadId, 'threadId')}.prompt.log`,
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/modules/agent/memory/trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const MEMORY_NAMESPACE = 'huabu.memory';
* the substrate, never a port member.
*/
function diskStatePath(substrate: SpaceSubstrate): string {
if (substrate.kind !== 'disk') {
throw new Error('Memory state requires a Disk extension substrate');
}
return path.join(substrate.directory, 'state.json');
}

Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/modules/canvas/canvas.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,9 @@ describe('Space export/import persistence', () => {
createCanvas('c1', 'Private Export');
const promptStore = await space('c1').extension('huabu.prompt.log');
const memoryStore = await space('c1').extension('huabu.memory');
if (!promptStore || !memoryStore) throw new Error('Expected Disk stores');
if (promptStore?.kind !== 'disk' || memoryStore?.kind !== 'disk') {
throw new Error('Expected Disk stores');
}
writeFileSync(
join(promptStore.directory, 'thread.prompt.log'),
'private system and user prompt',
Expand Down Expand Up @@ -806,7 +808,7 @@ describe('Space export/import persistence', () => {
const importedPrompt =
await space(importedId).extension('huabu.prompt.log');
const importedMemory = await space(importedId).extension('huabu.memory');
if (!importedPrompt || !importedMemory) {
if (importedPrompt?.kind !== 'disk' || importedMemory?.kind !== 'disk') {
throw new Error('Expected imported Disk stores');
}
expect(
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/modules/canvas/persistence-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/** Runtime validation shared by structured storage adapters. */

function finiteNumber(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value);
}

/** Return the first minimal CanvasFile shape violation, if any. */
export function canvasFileShapeError(
value: unknown,
expectedCanvasId: string,
): string | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return 'must be an object';
}

const record = value as Record<string, unknown>;
if (record['canvasId'] !== expectedCanvasId) {
return `canvasId must equal ${JSON.stringify(expectedCanvasId)}`;
}
if (record['title'] !== null && typeof record['title'] !== 'string') {
return 'title must be a string or null';
}
if (!finiteNumber(record['version']))
return 'version must be a finite number';
if (!finiteNumber(record['createdAt'])) {
return 'createdAt must be a finite number';
}
if (!finiteNumber(record['updatedAt'])) {
return 'updatedAt must be a finite number';
}

const state = record['state'];
if (typeof state !== 'object' || state === null || Array.isArray(state)) {
return 'state must be an object';
}
const stateRecord = state as Record<string, unknown>;
if (!Array.isArray(stateRecord['nodes']))
return 'state.nodes must be an array';
if (!Array.isArray(stateRecord['edges']))
return 'state.edges must be an array';
return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ describeSpaceNodesContract('Disk', async () => {
if (!created.ok) throw new Error('Node contract Space already exists');

const store = new DiskStructuredStore();
const space = store.space('node-space');
return {
repository: store.space('node-space').nodes,
repository: space.nodes,
missingRepository: store.space('missing-node-space').nodes,
expectedCanvasId: 'node-space',
deletedNodePut: 'write-suppressed',
cleanup: () => {
vi.restoreAllMocks();
resetStorageCache();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,55 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/** Runtime validation shared by strict Disk Space-record boundaries. */
/** Runtime validation and strict reads for Disk Space-record boundaries. */

import { readJsonStrict } from '../../../../utils/fs.js';
import { canvasFileShapeError } from '../../../canvas/persistence-validation.js';

import type { CanvasFile } from '../../../canvas/persistence-types.js';

function finiteNumber(value: unknown): boolean {
return typeof value === 'number' && Number.isFinite(value);
}

/** Return the first minimal {@link CanvasFile} shape violation, if any. */
export function canvasFileShapeError(
value: unknown,
expectedCanvasId: string,
): string | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return 'must be an object';
}

const record = value as Record<string, unknown>;
if (record['canvasId'] !== expectedCanvasId) {
return `canvasId must equal ${JSON.stringify(expectedCanvasId)}`;
}
if (record['title'] !== null && typeof record['title'] !== 'string') {
return 'title must be a string or null';
}
if (!finiteNumber(record['version'])) {
return 'version must be a finite number';
}
if (!finiteNumber(record['createdAt'])) {
return 'createdAt must be a finite number';
}
if (!finiteNumber(record['updatedAt'])) {
return 'updatedAt must be a finite number';
}

const state = record['state'];
if (typeof state !== 'object' || state === null || Array.isArray(state)) {
return 'state must be an object';
}
const stateRecord = state as Record<string, unknown>;
if (!Array.isArray(stateRecord['nodes'])) {
return 'state.nodes must be an array';
}
if (!Array.isArray(stateRecord['edges'])) {
return 'state.edges must be an array';
}
return null;
}
export { canvasFileShapeError } from '../../../canvas/persistence-validation.js';

/**
* Strictly read and validate one indexed `space.json` path.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,12 @@ describeSpaceExtensionContract('Disk', () => {
// An owner of a Disk namespace writes files into its directory; nothing
// about the shape is storage's business, so the suite borrows the
// simplest one an owner could pick.
write: (substrate, value) =>
writeFileSync(path.join(substrate.directory, 'value'), value, 'utf8'),
write: (substrate, value) => {
if (substrate.kind !== 'disk') throw new Error('Expected Disk substrate');
writeFileSync(path.join(substrate.directory, 'value'), value, 'utf8');
},
read: (substrate) => {
if (substrate.kind !== 'disk') throw new Error('Expected Disk substrate');
const file = path.join(substrate.directory, 'value');
return existsSync(file) ? readFileSync(file, 'utf8') : null;
},
Expand Down
153 changes: 28 additions & 125 deletions apps/server/src/modules/storage/backends/disk/structured-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { DiskStructuredStore } from './structured-store.js';
import { toSafeFilename } from '../../../../utils/naming.js';
import { describeSpaceLogsContract } from '../../ports/contracts/space-logs.contract.js';
import { describeSpaceTasksContract } from '../../ports/contracts/space-tasks.contract.js';
import { describeStructuredStoreContract } from '../../ports/contracts/structured-store.contract.js';

import type { CanvasFile } from '../../../canvas/persistence-types.js';
Expand Down Expand Up @@ -111,6 +112,8 @@ describe('Disk Space extension workspace binding', () => {
try {
const substrate = await pending;
expect(substrate?.kind).toBe('disk');
if (substrate?.kind !== 'disk')
throw new Error('Expected Disk substrate');
expect(substrate?.directory.startsWith(`${firstRoot}${path.sep}`)).toBe(
true,
);
Expand All @@ -125,6 +128,30 @@ describe('Disk Space extension workspace binding', () => {
});
});

describeSpaceTasksContract('Disk', () => {
const root = freshWorkspace('huabu-task-contract-');
seedSpace(root, 'canvas-task', 'Canvas Task');
const store = new DiskStructuredStore();
return {
tasks: store.space('canvas-task').tasks,
concurrent: store.space('canvas-task').tasks,
canvasId: 'canvas-task',
missing: store.space('missing-canvas').tasks,
missingCanvasId: 'missing-canvas',
beginDelete: async () => {
const result = await store.spaces().beginDelete({
canvasId: 'canvas-task',
});
if (!result.ok) throw new Error('Ordinary Space must be deletable');
return result.session;
},
cleanup: () => {
resetStorageCache();
rmSync(root, { recursive: true, force: true });
},
};
});

describe('Disk Space Tasks', () => {
let root = '';
let store: DiskStructuredStore;
Expand All @@ -141,132 +168,8 @@ describe('Disk Space Tasks', () => {
rmSync(root, { recursive: true, force: true });
});

it('serializes Task and Run mutations across independent handles', async () => {
const first = store.space('canvas-task').tasks;
const second = store.space('canvas-task').tasks;
await Promise.all([
first.create({
taskId: 'task-a',
canvasId: 'canvas-task',
goal: 'Goal A',
defaultRootProfileId: 'profile-a',
anchorNodeId: 'node-a',
createdAt: 1,
}),
second.create({
taskId: 'task-b',
canvasId: 'canvas-task',
goal: 'Goal B',
defaultRootProfileId: 'profile-b',
anchorNodeId: 'node-b',
createdAt: 2,
}),
]);
await first.runs.create({
runId: 'run-a',
taskId: 'task-a',
canvasIdSnapshot: 'canvas-task',
goalSnapshot: 'Goal A',
rootProfileIdSnapshot: 'profile-a',
status: 'pending',
createdAt: 3,
});
const updated = await second.runs.update('run-a', {
rootNodeId: 'node-root',
rootThreadId: 'thread-root',
status: 'running',
startedAt: 4,
});

expect(updated.status).toBe('running');
await expect(first.read()).resolves.toMatchObject({
version: 1,
tasks: [
expect.objectContaining({ taskId: 'task-a' }),
expect.objectContaining({ taskId: 'task-b' }),
],
runs: [
expect.objectContaining({
runId: 'run-a',
rootNodeId: 'node-root',
rootThreadId: 'thread-root',
}),
],
});
});

it('returns an empty versioned snapshot when no Task store exists', async () => {
await expect(store.space('canvas-empty').tasks.read()).resolves.toEqual({
version: 1,
tasks: [],
runs: [],
});
});

it('completes a running Run atomically and keeps its message immutable', async () => {
const runs = store.space('canvas-task').tasks.runs;
await expect(
runs.complete('task-a', 'run-a', {
completedAt: 5,
message: 'PR merged',
}),
).resolves.toMatchObject({
outcome: 'completed',
run: {
status: 'completed',
completion: { completedAt: 5, message: 'PR merged' },
},
});
await expect(
runs.complete('task-a', 'run-a', {
completedAt: 6,
message: 'PR merged',
}),
).resolves.toMatchObject({
outcome: 'unchanged',
run: { completion: { completedAt: 5, message: 'PR merged' } },
});
await expect(
runs.complete('task-a', 'run-a', {
completedAt: 7,
message: 'Different result',
}),
).resolves.toMatchObject({ outcome: 'completion_conflict' });

await runs.create({
runId: 'run-pending',
taskId: 'task-b',
canvasIdSnapshot: 'canvas-task',
goalSnapshot: 'Goal B',
rootProfileIdSnapshot: 'profile-b',
status: 'pending',
createdAt: 8,
});
await expect(
runs.complete('task-b', 'run-pending', { completedAt: 9 }),
).resolves.toMatchObject({ outcome: 'run_not_running' });
await expect(
runs.complete('missing-task', 'run-a', { completedAt: 9 }),
).resolves.toEqual({ outcome: 'task_not_found' });
await expect(
runs.complete('task-a', 'missing-run', { completedAt: 9 }),
).resolves.toEqual({ outcome: 'run_not_found' });
});

it('rejects mutations for a missing Space', async () => {
await expect(
store.space('missing-canvas').tasks.create({
taskId: 'task-missing',
canvasId: 'missing-canvas',
goal: 'Missing',
defaultRootProfileId: 'profile-a',
anchorNodeId: 'node-missing',
createdAt: 1,
}),
).rejects.toThrow(/cannot write a missing Space/);
});

it('fails fast on malformed and internally inconsistent Task stores', async () => {
mkdirSync(path.dirname(tasksPath('canvas-task')), { recursive: true });
writeFileSync(tasksPath('canvas-task'), '{"version":1,"tasks":{}}');
await expect(store.space('canvas-task').tasks.read()).rejects.toThrow(
/Invalid Task store/,
Expand Down
Loading
Loading