Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
74ac5cd
feat(storage): add Phase 5 SQLite contract preview
ultmaster Aug 13, 2026
5983aca
refactor(storage): split the SQLite values grab-bag by dependency
ultmaster Aug 14, 2026
251dd11
fix(storage): complete SQLite task-run parity
ultmaster Aug 17, 2026
3b96a40
docs(storage): refresh Phase 5 after boundary merge
ultmaster Aug 18, 2026
5569913
fix(storage): align SQLite preview with current contracts
ultmaster Sep 4, 2026
30972f4
feat(storage): make SQLite a profile the app can run on
ultmaster Sep 4, 2026
bf50536
docs(storage): record what the SQLite profile serves and what it does…
ultmaster Sep 4, 2026
00cf986
refactor(storage): keep blob bytes on a file system, whatever holds t…
ultmaster Sep 8, 2026
d92bc4e
refactor(storage): give each backend one owner for its area of the da…
ultmaster Sep 8, 2026
52e4b21
docs(storage): say which layer decides where a Space's bytes go
ultmaster Sep 8, 2026
8dc8a0f
docs(storage): key the capability matrix on the structured backend, o…
ultmaster Sep 8, 2026
2437008
refactor(storage): key the capability matrix on the profile, and gate…
ultmaster Sep 8, 2026
118e3db
refactor(storage): state a capability's requirement instead of two lists
ultmaster Sep 8, 2026
dee4919
fix(storage): say what reveal actually opens, and drop a mis-pasted c…
ultmaster Sep 8, 2026
a505545
fix(storage): surface unsupported Space import and export errors
ultmaster Sep 10, 2026
92512f9
test(web): let the e2e suite boot a backend without a Workspace folder
ultmaster Sep 11, 2026
b963b5d
docs(storage): document SQLite environment settings
ultmaster Sep 11, 2026
0a9df5c
fix(desktop): preserve Node built-in prefixes in server bundles
ultmaster Sep 11, 2026
7afed43
fix(storage): address workspace and conversation review findings
ultmaster Sep 11, 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
34 changes: 32 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,39 @@
# HUABU_BASIC_AUTH_USER=
# HUABU_BASIC_AUTH_PASS=

# ── Managed workspace mode ──
# ── Storage (restart required) ──
# Structured records: disk (default) or sqlite. Postgres is not implemented.
# To use SQLite, set this to sqlite and leave HUABU_WORKSPACE unset in both
# the shell and .env files. First launch creates a named Workspace; later
# launches reopen the most recently used Workspace. The UI has no SQLite
# Workspace picker; named Workspace creation/switching is available via API.
# Changing backends does not migrate existing data.
# HUABU_STRUCTURED_BACKEND=disk
#
# Attachments and other Space bytes remain files with either record backend.
# disk is the only implemented blob backend; Azure is not available yet.
# HUABU_BLOB_BACKEND=disk
#
# SQLite database file. Default: <HUABU_DATA_DIR>/storage/sqlite/huabu.sqlite.
# Only used with HUABU_STRUCTURED_BACKEND=sqlite; does not move blob files.
# HUABU_SQLITE_PATH=/var/lib/huabu/storage/sqlite/huabu.sqlite
#
# Blob base directory for SQLite records. Each Space uses a subdirectory
# <workspaceId>/<canvasId>/. Default: <HUABU_DATA_DIR>/storage/disk/blobs.
# Ignored with disk records, where bytes stay inside each Space folder.
# HUABU_BLOB_ROOT=/var/lib/huabu/storage/disk/blobs
#
# Default data directory: apps/server/data for source server launches and
# dev:desktop; <Electron userData>/data for start:desktop and installed apps.
# For desktop, pass storage settings in the app process environment; there
# is no in-app storage selector. Installed apps do not load this repo .env.
# Restart the server or fully quit and relaunch desktop after changes.

# ── Managed workspace mode (disk records only) ──
# Locks workspace to this absolute path; disables folder picker.
# Unset = free mode (clients choose any directory).
# Unset = free mode (disk clients choose a directory; SQLite opens its own
# Workspace). Setting this with SQLite fails startup; SQLite managed mode
# is not supported yet.
# HUABU_WORKSPACE=/var/lib/huabu/team-a

# ══════════════════════════════════════════════════════════════════
Expand Down
60 changes: 60 additions & 0 deletions apps/server/src/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { build } from 'tsup';
import { describe, expect, it } from 'vitest';

import bundleConfig from '../tsup.config.js';

const configured =
typeof bundleConfig === 'function' ? await bundleConfig({}) : bundleConfig;
const configurations = Array.isArray(configured) ? configured : [configured];

describe('server bundle runtime', () => {
it.each(configurations)(
'loads prefix-only Node built-ins with the $outDir configuration',
async (configuration) => {
const directory = mkdtempSync(path.join(tmpdir(), 'huabu-bundle-test-'));
try {
const entry = path.join(directory, 'probe.ts');
writeFileSync(
entry,
`import { writeFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
const database = new DatabaseSync(':memory:');
writeFileSync(process.argv[2], JSON.stringify(database.prepare('SELECT 42 AS answer').get()));
database.close();
`,
);
const outDir = path.join(directory, 'dist');
// Exercise the production bundler options with a small real entry.
// Only redirect artifacts and omit production asset copying.
await build({
...configuration,
config: false,
entry: { probe: entry },
outDir,
outExtension: () => ({ js: '.mjs' }),
onSuccess: undefined,
silent: true,
});
const resultFile = path.join(directory, 'result.json');
execFileSync(
process.execPath,
[path.join(outDir, 'probe.mjs'), resultFile],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
);
expect(JSON.parse(readFileSync(resultFile, 'utf8'))).toEqual({
answer: 42,
});
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
);
});
132 changes: 132 additions & 0 deletions apps/server/src/modules/agent/agenetes/conversation-stores.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Which Agenetes conversation stores this deployment runs on.
*
* Agenetes takes its three storage ports at mount, once, while the storage
* profile is only known at runtime and the active Workspace can change under
* a running process. So the mounted stores are dispatchers: each call picks
* the implementation that suits the namespace it was handed.
*
* The choice is made per namespace rather than per process because that is
* where the answer actually lives. A namespace carries a `storage.root` when
* the Space it belongs to is a directory, and does not when it is rows — the
* same fact the Space facade reports, arriving here through Agenetes's own
* vocabulary.
*
* The in-memory fall-through is not a backend choice. It is what an *unnamed*
* namespace has always got: a conversation with no Space to belong to, which
* Agenetes explicitly treats as non-persistent.
*/

import {
FileEventLogStore,
FileThreadStore,
FileTurnStore,
InMemoryEventLogStore,
InMemoryThreadStore,
InMemoryTurnStore,
} from '@agenetes/agenetes';

import {
conversationTables,
SqliteEventLogStore,
SqliteThreadStore,
SqliteTurnStore,
} from './sqlite-stores.js';

import type {
EventLogEntry,
EventLogRecord,
EventLogStore,
PersistedTurn,
ThreadRecord,
ThreadStore,
TurnStartLogEntry,
TurnStore,
} from '@agenetes/agenetes';
import type { AgentSubmission, Namespace } from '@agenetes/protocol';

interface Backing {
readonly threads: ThreadStore;
readonly events: EventLogStore;
readonly turns: TurnStore;
}

const file: Backing = {
threads: new FileThreadStore(),
events: new FileEventLogStore(),
turns: new FileTurnStore(),
};

const sqlite: Backing = {
threads: new SqliteThreadStore(),
events: new SqliteEventLogStore(),
turns: new SqliteTurnStore(),
};

/**
* Shared, so an unnamed namespace keeps one conversation for the life of the
* process instead of a fresh empty one per port.
*/
const memory: Backing = {
threads: new InMemoryThreadStore(),
events: new InMemoryEventLogStore(),
turns: new InMemoryTurnStore(),
};

/** The stores that own this namespace's durable conversation state. */
function backingFor(namespace: Namespace): Backing {
// A directory to write into settles it: that is the Disk profile, and the
// file stores are what wrote whatever is already there.
if (namespace.storage?.root) return file;
if (namespace.name && conversationTables(namespace) !== null) return sqlite;
return memory;
}

export const conversationThreadStore: ThreadStore = {
upsert: (namespace, threadId, record: ThreadRecord) =>
backingFor(namespace).threads.upsert(namespace, threadId, record),
get: (namespace, threadId) =>
backingFor(namespace).threads.get(namespace, threadId),
list: (namespace) => backingFor(namespace).threads.list(namespace),
delete: (namespace, threadId) =>
backingFor(namespace).threads.delete(namespace, threadId),
};

export const conversationEventLogStore: EventLogStore = {
appendTurnStart: (
namespace,
threadId,
request: AgentSubmission | null,
): TurnStartLogEntry =>
backingFor(namespace).events.appendTurnStart(namespace, threadId, request),
append: (namespace, threadId, event): EventLogEntry =>
backingFor(namespace).events.append(namespace, threadId, event),
read: (namespace, threadId, sinceSeq) =>
backingFor(namespace).events.read(namespace, threadId, sinceSeq),
readRecords: (namespace, threadId, sinceSeq) =>
backingFor(namespace).events.readRecords(namespace, threadId, sinceSeq),
maxSeq: (namespace, threadId) =>
backingFor(namespace).events.maxSeq(namespace, threadId),
replace: (namespace, threadId, records: readonly EventLogRecord[]) =>
backingFor(namespace).events.replace(namespace, threadId, records),
delete: (namespace, threadId) =>
backingFor(namespace).events.delete(namespace, threadId),
};

export const conversationTurnStore: TurnStore = {
append: (namespace, threadId, persisted: PersistedTurn) =>
backingFor(namespace).turns.append(namespace, threadId, persisted),
list: (namespace, threadId) =>
backingFor(namespace).turns.list(namespace, threadId),
count: (namespace, threadId) =>
backingFor(namespace).turns.count(namespace, threadId),
fence: (namespace, threadId) =>
backingFor(namespace).turns.fence(namespace, threadId),
replace: (namespace, threadId, persisted: readonly PersistedTurn[]) =>
backingFor(namespace).turns.replace(namespace, threadId, persisted),
delete: (namespace, threadId) =>
backingFor(namespace).turns.delete(namespace, threadId),
};
20 changes: 11 additions & 9 deletions apps/server/src/modules/agent/agenetes/drivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import {
type AcpCreateSpec,
type AcpTurnCtx,
} from '@agenetes/acp-driver';
import {
FileEventLogStore,
FileThreadStore,
FileTurnStore,
mountAgenetes,
} from '@agenetes/agenetes';
import { mountAgenetes } from '@agenetes/agenetes';
import { getAgentTeamRegistry } from '@agenetes/agentlet-host';
import { piDriverFactory, type PiTurnCtx } from '@agenetes/pi-driver';

import {
conversationEventLogStore,
conversationThreadStore,
conversationTurnStore,
} from './conversation-stores.js';
import { type AgentHandle } from './handle.js';
import { HISTORY_LOAD_SANITY_LIMIT } from './history-replay.js';
import { huabuPiDriverPorts } from './pi-driver.js';
Expand Down Expand Up @@ -62,9 +62,11 @@ export const agenetes: Agenetes = mountAgenetes({
[INTERNAL_DRIVER_KIND]: piDriverFactory({ ports: huabuPiDriverPorts }),
[EXTERNAL_DRIVER_KIND]: externalDriver,
},
threadStore: new FileThreadStore(),
eventLogStore: new FileEventLogStore(),
turnStore: new FileTurnStore(),
// Dispatchers, not one backing: which store owns a conversation depends on
// where its Space lives, and that is a runtime fact (`conversation-stores`).
threadStore: conversationThreadStore,
eventLogStore: conversationEventLogStore,
turnStore: conversationTurnStore,
// Corruption guard, not a context budget: replay restores whatever the
// live handle would still be holding, and trimming that is the
// conversation's problem, not recovery's.
Expand Down
Loading
Loading