Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9641f7d
docs(storage): respecify phase 4.6 around the four dispositions
ultmaster Aug 19, 2026
470437a
feat(storage): complete the portable node read surface and World boot…
ultmaster Aug 19, 2026
d249505
feat(storage): reach one Space through one handle
ultmaster Aug 19, 2026
2938896
refactor(agent): read Agent-thread targets through the ports
ultmaster Aug 19, 2026
5305147
refactor(canvas): read World, spatial, and prompt context through the…
ultmaster Aug 19, 2026
da953bf
refactor: move the remaining feature reads onto the storage ports
ultmaster Aug 19, 2026
c6fe3ac
refactor(canvas): finish the portable read surface and retire a shim
ultmaster Aug 19, 2026
abf0bcf
feat(storage): mount storage onto a Workspace as an explicit lifecycle
ultmaster Aug 19, 2026
20dd70e
feat(storage): hand extensions a place, not a data API
ultmaster Aug 20, 2026
4a5fd68
feat(storage): give each user-visible area of a Space its own blob scope
ultmaster Aug 20, 2026
e23964d
feat(storage): declare what a profile cannot do, not just what it can…
ultmaster Aug 20, 2026
076437c
test(storage): prove the exit criterion against a mounted backend
ultmaster Aug 20, 2026
6526af6
docs(storage): record phase 4.6 as implemented
ultmaster Aug 20, 2026
b0155d0
refactor(storage): close the last Disk-layout imports outside the bou…
ultmaster Aug 20, 2026
6724716
docs(storage): name the Disk-only capabilities and their guard
ultmaster Aug 20, 2026
96debc6
refactor(storage): reach a Space's bytes the same way as its records
ultmaster Aug 20, 2026
f43a3cf
feat(workspace): serve one workspace per process, change it by restart
ultmaster Aug 20, 2026
cb65407
docs(storage): record the symmetric ports and the one-Workspace process
ultmaster Aug 20, 2026
8bb382e
refactor(storage): stop asking which workspace a Disk handle belongs to
ultmaster Aug 20, 2026
4a67c36
fix(workspace): do not retry a startup workspace the server already f…
ultmaster Aug 21, 2026
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
36 changes: 30 additions & 6 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,9 +456,8 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv {

// Ensure the data directory exists so the server doesn't have to
// race-condition on first-use creation. The workspace directory is
// intentionally NOT pre-created: in free mode the user picks it via
// the in-app UI (folder picker / path input), and the web client
// persists the selection across launches via localStorage.
// intentionally NOT pre-created: on first launch the user picks it via the
// in-app UI (folder picker / path input), and we remember it from then on.
mkdirSync(dataDir, { recursive: true });

if (IS_DEV && webDistPath && !existsSync(webDistPath)) {
Expand All @@ -469,9 +468,6 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv {
);
}

// Notably absent: HUABU_WORKSPACE. Omitting it puts the server in
// free mode, so the web UI shows its workspace picker on first launch.
//
// External-agent (ACP) integration: the server embeds an `agentlet`
// daemon supervisor (`DaemonSupervisor`) which fork()s the daemon
// entry point itself. In packaged builds the entry resolves to
Expand All @@ -480,12 +476,27 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv {
// in dev it falls back to `external/agentlet/packages/local/dist/index.js`.
// No env var injection is needed here \u2014 the resolver in
// `daemon-supervisor.ts` covers both layouts.

// The workspace the user last chose, handed to the server at fork.
//
// A server process serves one workspace for its lifetime, so this is where
// the choice takes effect — the shell owns both `workspace.json` and the
// child process, which makes it the only thing that can apply a new one.
// Deliberately *not* `HUABU_WORKSPACE`: that is the operator's lock, and it
// hides the path, removes the picker, and fails the boot when the folder
// cannot be opened. This is the user's own choice, so it stays free mode —
// the picker remains available and a folder that has gone missing lands the
// user back on it instead of killing the app. Absent on first launch, which
// is what shows the picker then.
const savedWorkspace = readWorkspaceStore().path;

return {
...process.env,
SERVER_PORT: String(port),
HUABU_BIND_HOST: '127.0.0.1',
HUABU_DATA_DIR: dataDir,
HUABU_SECRET_BRIDGE: '1',
...(savedWorkspace ? { HUABU_WORKSPACE_STARTUP: savedWorkspace } : {}),
...(webDistPath ? { WEB_DIST_PATH: webDistPath } : {}),
NODE_ENV: IS_DEV ? 'development' : 'production',
};
Expand Down Expand Up @@ -786,6 +797,19 @@ function registerWorkspaceIpc(): void {
return next;
});

/**
* Restart the app so the server comes up on the saved workspace.
*
* The renderer calls this after `workspace:set` when a workspace is already
* active. Relaunching the whole app rather than re-forking the server keeps
* one rule about what a running process is looking at: the window, its
* caches, and the server all start again on the same choice.
*/
ipcMain.handle('workspace:restart', () => {
app.relaunch();
app.quit();
});

ipcMain.handle('workspace:remove-recent', (_event, rawPath: unknown) => {
if (typeof rawPath !== 'string') {
throw new Error('workspace:remove-recent requires a string path');
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ contextBridge.exposeInMainWorld('electronBridge', {
'workspace:remove-recent',
path,
) as Promise<WorkspaceStoreSnapshot>,
/**
* Restart the app onto the saved workspace.
*
* A server process serves one workspace for its lifetime, so this is how a
* new choice takes effect. Never resolves — the app is on its way down.
*/
restart: (): Promise<void> =>
ipcRenderer.invoke('workspace:restart') as Promise<void>,
},

window: {
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
originGuardPlugin,
resolveAllowedHostnames,
} from './modules/security/index.js';
import { closeStorage } from './modules/storage/index.js';
import webRoutes from './modules/web/web.route.js';
import {
initWorkspaceFromEnv,
Expand Down Expand Up @@ -364,6 +365,11 @@ if (bundledAgentTeamsPath) {
// after the process is gone. Closing them here lets `app.close()` (driven
// by the SIGTERM/SIGINT handlers in server.ts) tear them down gracefully.
app.addHook('onClose', async () => resetExternalNoteSessions());
// Close the mounted storage connections on graceful shutdown. Disk has
// nothing to release, but a connection-holding backend does, and a mount that
// outlives the process that owned it is exactly the kind of leak that only
// shows up under the backend nobody has written yet.
app.addHook('onClose', async () => closeStorage());
// Capture the bound TCP port for L1-owned reachback (RFS): the
// canvas-scoped `HUABU_RFS_URL` base is built from this. RFS is
// canvas-coupled and therefore a pure L1 concern, so the port lives in
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/modules/agent/agent-node.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function createHarness(options?: {
: null,
listSelectableProfileIds: () => options?.selectableIds ?? ['profile-a'],
}),
readCanvasNodes: () =>
readCanvasNodes: async () =>
options?.nodes === undefined
? [
{ id: NOTE_ID, type: 'note' },
Expand Down
18 changes: 10 additions & 8 deletions apps/server/src/modules/agent/agent-node.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
import { getLogger } from '../../utils/logger.js';
import { executeCanvasCommandsOnHost } from '../canvas/canvas-command-router.js';
import { buildSpatialBundle } from '../canvas/canvas-spatial.js';
import { getCanvasStore } from '../storage/index.js';
import { space } from '../storage/index.js';

import type { ExecuteOnServerOutput } from '../canvas/canvas-executor.js';

Expand Down Expand Up @@ -87,16 +87,18 @@ interface StoredNode {

interface AgentNodeServiceDependencies {
getProfileRegistry: () => AgentProfileRegistryPort | null;
readCanvasNodes: (canvasId: string) => StoredNode[] | null;
readCanvasNodes: (canvasId: string) => Promise<StoredNode[] | null>;
execute: (input: {
canvasId: string;
commands: readonly CanvasCommand[];
originator: { source: 'system' };
}) => Promise<ExecuteOnServerOutput>;
}

function defaultReadCanvasNodes(canvasId: string): StoredNode[] | null {
const canvas = getCanvasStore(canvasId).read();
async function defaultReadCanvasNodes(
canvasId: string,
): Promise<StoredNode[] | null> {
const canvas = await space(canvasId).read();
if (!canvas) return null;
return canvas.state.nodes as StoredNode[];
}
Expand Down Expand Up @@ -153,11 +155,11 @@ function resolveAnchor(
return nodeId;
}

export function resolveAgentNodePosition(
export async function resolveAgentNodePosition(
canvasId: string,
parentNodeId?: CanvasNodeId,
): Point {
const canvas = getCanvasStore(canvasId).read();
): Promise<Point> {
const canvas = await space(canvasId).read();
if (!canvas) {
throw new AgentNodeCreationError(
'canvas_not_found',
Expand Down Expand Up @@ -201,7 +203,7 @@ export class AgentNodeService {
throw error;
}

const nodes = this.dependencies.readCanvasNodes(input.canvasId);
const nodes = await this.dependencies.readCanvasNodes(input.canvasId);
if (!nodes) {
throw new AgentNodeCreationError(
'canvas_not_found',
Expand Down
44 changes: 24 additions & 20 deletions apps/server/src/modules/agent/agent-thread-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ function createResolver(
content = '',
) {
return new AgentThreadResolver({
readCanvasNodes: () => nodes,
readNodeContent: () => content,
readCanvasNodes: async () => nodes,
readNodeContent: async () => content,
});
}

Expand All @@ -41,8 +41,8 @@ const FIXED_NODE = {
};

describe('AgentThreadResolver', () => {
it('resolves a fixed external Agent Node from Canvas storage', () => {
const target = createResolver(
it('resolves a fixed external Agent Node from Canvas storage', async () => {
const target = await createResolver(
[FIXED_NODE],
'Existing prompt',
).resolveFixedAgentNode('canvas-a', 'thread-a');
Expand All @@ -65,36 +65,36 @@ describe('AgentThreadResolver', () => {
});
});

it('falls back for selectable or unrelated threads', () => {
it('falls back for selectable or unrelated threads', async () => {
const selectable = {
...FIXED_NODE,
data: { ...FIXED_NODE.data, agentBindingPolicy: 'selectable' },
};
expect(
await expect(
createResolver([selectable]).resolveFixedAgentNode(
'canvas-a',
'thread-a',
),
).toBeNull();
expect(
).resolves.toBeNull();
await expect(
createResolver([FIXED_NODE]).resolveFixedAgentNode(
'canvas-a',
'thread-other',
),
).toBeNull();
).resolves.toBeNull();
});

it('resolves any Question Node as a possible parent', () => {
it('resolves any Question Node as a possible parent', async () => {
const selectable = {
...FIXED_NODE,
data: { ...FIXED_NODE.data, agentBindingPolicy: 'selectable' },
};
expect(
await expect(
createResolver([selectable]).resolveAgentNodeId('canvas-a', 'thread-a'),
).toBe('node-agent');
).resolves.toBe('node-agent');
});

it('resolves a Huabu Agent binding for invocation', () => {
it('resolves a Huabu Agent binding for invocation', async () => {
const internal = {
...FIXED_NODE,
data: {
Expand All @@ -103,19 +103,23 @@ describe('AgentThreadResolver', () => {
},
};
expect(
createResolver([internal]).resolveFixedAgentNode('canvas-a', 'thread-a')
?.agentBinding,
(
await createResolver([internal]).resolveFixedAgentNode(
'canvas-a',
'thread-a',
)
)?.agentBinding,
).toEqual({ kind: 'internal' });
});

it('rejects duplicate threads and corrupt fixed-node metadata', () => {
it('rejects duplicate threads and corrupt fixed-node metadata', async () => {
const duplicateResolver = createResolver([
FIXED_NODE,
{ ...FIXED_NODE, id: 'node-agent-2' },
]);
expect(() =>
await expect(
duplicateResolver.resolveFixedAgentNode('canvas-a', 'thread-a'),
).toThrowError(
).rejects.toThrowError(
expect.objectContaining<Partial<AgentThreadResolutionError>>({
code: 'duplicate_thread',
}),
Expand All @@ -124,9 +128,9 @@ describe('AgentThreadResolver', () => {
const corruptResolver = createResolver([
{ ...FIXED_NODE, data: { ...FIXED_NODE.data, agentBinding: null } },
]);
expect(() =>
await expect(
corruptResolver.resolveFixedAgentNode('canvas-a', 'thread-a'),
).toThrowError(
).rejects.toThrowError(
expect.objectContaining<Partial<AgentThreadResolutionError>>({
code: 'invalid_binding',
}),
Expand Down
29 changes: 16 additions & 13 deletions apps/server/src/modules/agent/agent-thread-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
InvalidAgentLaunchOverridesError,
parseAgentLaunchOverrides,
} from './agent-launch-overrides.js';
import { getCanvasStore } from '../storage/index.js';
import { space } from '../storage/index.js';

interface StoredNode {
id: string;
Expand All @@ -23,8 +23,8 @@ interface StoredNode {
}

interface ResolverDependencies {
readCanvasNodes: (canvasId: string) => StoredNode[] | null;
readNodeContent: (canvasId: string, nodeId: string) => string | null;
readCanvasNodes: (canvasId: string) => Promise<StoredNode[] | null>;
readNodeContent: (canvasId: string, nodeId: string) => Promise<string | null>;
}

export interface FixedAgentNodeTarget {
Expand Down Expand Up @@ -55,12 +55,12 @@ export class AgentThreadResolutionError extends Error {
}

const DEFAULT_DEPENDENCIES: ResolverDependencies = {
readCanvasNodes: (canvasId) => {
const canvas = getCanvasStore(canvasId).read();
readCanvasNodes: async (canvasId) => {
const canvas = await space(canvasId).read();
return canvas ? (canvas.state.nodes as StoredNode[]) : null;
},
readNodeContent: (canvasId, nodeId) =>
getCanvasStore(canvasId).readNode(nodeId)?.content ?? null,
readNodeContent: async (canvasId, nodeId) =>
(await space(canvasId).nodes.read(nodeId))?.record.content ?? null,
};

/**
Expand All @@ -74,8 +74,11 @@ export class AgentThreadResolver {
private readonly dependencies: ResolverDependencies = DEFAULT_DEPENDENCIES,
) {}

resolveAgentNodeId(canvasId: string, threadId: string): CanvasNodeId | null {
const nodes = this.dependencies.readCanvasNodes(canvasId);
async resolveAgentNodeId(
canvasId: string,
threadId: string,
): Promise<CanvasNodeId | null> {
const nodes = await this.dependencies.readCanvasNodes(canvasId);
if (!nodes) {
throw new AgentThreadResolutionError(
'canvas_not_found',
Expand All @@ -93,11 +96,11 @@ export class AgentThreadResolver {
return node?.type === 'question' ? (node.id as CanvasNodeId) : null;
}

resolveFixedAgentNode(
async resolveFixedAgentNode(
canvasId: string,
threadId: string,
): FixedAgentNodeTarget | null {
const nodes = this.dependencies.readCanvasNodes(canvasId);
): Promise<FixedAgentNodeTarget | null> {
const nodes = await this.dependencies.readCanvasNodes(canvasId);
if (!nodes) {
throw new AgentThreadResolutionError(
'canvas_not_found',
Expand Down Expand Up @@ -145,7 +148,7 @@ export class AgentThreadResolver {
throw error;
}

const content = this.dependencies.readNodeContent(canvasId, node.id);
const content = await this.dependencies.readNodeContent(canvasId, node.id);
if (content === null) {
throw new AgentThreadResolutionError(
'missing_node_content',
Expand Down
Loading