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
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 @@ -53,8 +53,10 @@ 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,
space,
missingRepository: store.space('missing-node-space').nodes,
expectedCanvasId: 'node-space',
cleanup: () => {
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
154 changes: 29 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 @@ -22,6 +22,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 @@ -90,6 +91,33 @@ describeSpaceLogsContract('Disk Space logs', () => {
};
});

describeSpaceTasksContract('Disk', () => {
const root = freshWorkspace('huabu-task-contract-');
seedSpace(root, 'canvas-task', 'Canvas Task');
const store = new DiskStructuredStore();
const tasks = store.space('canvas-task').tasks;
const concurrent = store.space('canvas-task').tasks;

return {
tasks,
concurrent,
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 @@ -106,132 +134,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