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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,11 @@ HUB_STATIC_DIR=../web/dist
# callback URL of <BASE_URL>/api/auth/callback/github.
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=

# Optional firm-memory plane (@corbits/memory). Same Postgres cluster is fine;
# schema is `knowledge`. Leave unset to boot without memory.
# KNOWLEDGE_DATABASE_URL=postgres://workbench:workbench@localhost:5432/workbench
# EMBED_BASE_URL=https://api.openai.com/v1
# EMBED_MODEL=text-embedding-3-small
# EMBED_API_KEY=

1 change: 1 addition & 0 deletions apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@corbits/chat": "workspace:*",
"@corbits/commands": "workspace:*",
"@corbits/folded-runs": "workspace:*",
"@corbits/memory": "github:corbitsdev/corbits-memory#800340252dc60393f017fa933c59a1c9e67e7264",
"@corbits/routines": "workspace:*",
"@corbits/webhook-triggers": "workspace:*",
"@intx/authz": "workspace:*",
Expand Down
10 changes: 10 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { createNeedsYouRoutes } from "@corbits/approvals";
import { createEchoRoutes } from "@workbench/echo";
import { createGitWorkflowPusher } from "@workbench/hub-client";
import { createOnboardingRoutes } from "@workbench/onboarding";
import { mountMemory } from "./memory-mount";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { type Context, type Next } from "hono";
Expand Down Expand Up @@ -464,6 +465,15 @@ export async function createHub(config: HubConfig) {
launcher: routineLauncher,
});

// Memory plane (optional): firm-memory HTTP under
// `/api/tenants/:tenantId/memory/*`. Degrades when
// KNOWLEDGE_DATABASE_URL / EMBED_* are unset — see memory-mount.ts.
mountMemory({
app,
grantStore: chatGrantStore,
conditionRegistry: chatConditionRegistry,
});

// The first-login hook mounts outside the tenant prefix, since the
// session it serves belongs to no tenant yet. The route is
// `@workbench/onboarding`'s; what it decides is documented in that
Expand Down
63 changes: 63 additions & 0 deletions apps/hub/src/memory-mount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, test } from "bun:test";
import { Hono } from "hono";

import { mountMemory } from "./memory-mount";

const KEYS = [
"KNOWLEDGE_DATABASE_URL",
"EMBED_BASE_URL",
"EMBED_MODEL",
"EMBED_API_STYLE",
"EMBED_API_KEY",
] as const;

type EnvKey = (typeof KEYS)[number];

const saved: Partial<Record<EnvKey, string | undefined>> = {};

function clearEnvKey(key: EnvKey): void {
// Prefer assignment over `delete process.env[key]` — eslint forbids dynamic delete.
process.env[key] = undefined;
}

afterEach(() => {
for (const key of KEYS) {
const value = saved[key];
if (value === undefined) clearEnvKey(key);
else process.env[key] = value;
saved[key] = undefined;
}
});

function stashEnv(): void {
for (const key of KEYS) {
saved[key] = process.env[key];
clearEnvKey(key);
}
}

describe("mountMemory", () => {
test("returns undefined when KNOWLEDGE_DATABASE_URL is unset (optional)", () => {
stashEnv();
const app = new Hono();
const handle = mountMemory({
app,
grantStore: {} as never,
conditionRegistry: {},
});
expect(handle).toBeUndefined();
});

test("throws when optional is false and env is missing", () => {
stashEnv();
const app = new Hono();
expect(() =>
mountMemory({
app,
grantStore: {} as never,
conditionRegistry: {},
optional: false,
}),
).toThrow(/KNOWLEDGE_DATABASE_URL/);
});
});
73 changes: 73 additions & 0 deletions apps/hub/src/memory-mount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Hub-side memory plane mount — host analog of Scout dock's `mountKnowledge`
* (`packages/agent-dock/src/knowledge.ts`). `@corbits/memory` (git pin) owns
* the firm-memory plane + HTTP under `/api/tenants/:tenantId/memory/*`.
*
* Degrades cleanly when unconfigured: missing `KNOWLEDGE_DATABASE_URL` (or
* embed env) means "no memory plane", logged once at boot, never thrown —
* same optional-engine contract as the artifacts mount. When env is present,
* boot fails loudly if create/load throws so a half-wired deploy is never
* silent.
*
* This module lands the mount + factory only. Capture/ingestion glue is a
* later ticket; agents that want firm memory ask the returned handle.
*
* Boundary casts (`as never`) match Scout: `@corbits/memory` ships against its
* own Hono/authz type copies, and Hono env + ConditionRegistry are invariant
* across package roots — cast only at the mount boundary.
*/
import type { Hono } from "hono";
import type { ConditionRegistry, GrantStore } from "@intx/authz";
import { getLogger } from "@intx/log";
import { createMemory, loadMemoryConfig, type Memory } from "@corbits/memory";

const log = getLogger(["hub", "memory-mount"]);

export type MountMemoryOptions<E extends object = object> = {
/** Hub Hono app (routes register under tenant memory paths). */
app: Hono<E>;
grantStore: GrantStore;
conditionRegistry: ConditionRegistry;
/**
* When true (default), skip mount if `KNOWLEDGE_DATABASE_URL` is unset.
* Tests can force a mount attempt by setting env + `optional: false`.
*/
optional?: boolean;
};

export type MemoryMountHandle = {
memory: Memory;
};

/**
* Returns a memory handle when the plane is configured and mounted; `undefined`
* when optional and env is absent.
*/
export function mountMemory<E extends object = object>(
options: MountMemoryOptions<E>,
): MemoryMountHandle | undefined {
const optional = options.optional !== false;
const knowledgeUrl = process.env["KNOWLEDGE_DATABASE_URL"];
if (!knowledgeUrl) {
if (optional) {
log.info(
"KNOWLEDGE_DATABASE_URL not set — memory plane will not be mounted",
);
return undefined;
}
throw new Error("KNOWLEDGE_DATABASE_URL is required to mount memory");
}

// loadMemoryConfig also requires EMBED_* — fail at boot when env is partial.
const config = loadMemoryConfig();
// Cast grant/condition/app at the package boundary — see module doc.
const memory = createMemory({
app: options.app as never,
config,
grantStore: options.grantStore as never,
conditionRegistry: options.conditionRegistry as never,
});

log.info("Memory plane mounted at /api/tenants/:tenantId/memory/*");
return { memory };
}
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading