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
32 changes: 30 additions & 2 deletions builds/typescript/client_web/src/api/gateway-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type GatewayMemoryBackupRunResponse,
type GatewayMemoryBackupSettingsUpdateRequest,
type GatewayMigrationImportResult,
type GatewayMemoryUpdateStatus,
type GatewayModelCatalog,
type GatewayOnboardingStatus,
type GatewaySkillBinding,
Expand Down Expand Up @@ -626,8 +627,35 @@ export async function restoreMemoryBackup(

return (await response.json()) as GatewayMemoryBackupRestoreResponse;
}

export async function getOwnerProfile(): Promise<string | null> {

export async function getMemoryUpdateStatus(): Promise<GatewayMemoryUpdateStatus> {
const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/updates/memory/status`, {
headers: withLocalOwnerHeaders(),
});

if (!response.ok) {
throw await toGatewayError(response);
}

return (await response.json()) as GatewayMemoryUpdateStatus;
}

export async function getMemoryUpdateReport(migrationId: string): Promise<string> {
const response = await authenticatedFetch(
`${GATEWAY_BASE_URL}/updates/memory/reports/${encodeURIComponent(migrationId)}`,
{
headers: withLocalOwnerHeaders(),
}
);

if (!response.ok) {
throw await toGatewayError(response);
}

return response.text();
}

export async function getOwnerProfile(): Promise<string | null> {
const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/profile`, {
headers: withLocalOwnerHeaders(),
});
Expand Down
11 changes: 11 additions & 0 deletions builds/typescript/client_web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,17 @@ export type GatewayMigrationImportResult = {
logout_required?: boolean;
};

export type GatewayMemoryUpdateStatus = {
current_app_version: string;
memory_pack_version: string;
target_memory_pack_version: string;
pending: boolean;
migration_id: string;
report_path: string | null;
applied_paths: string[];
deferred_paths: string[];
};

export class GatewayError extends Error {
readonly status: number;
readonly code?: string;
Expand Down
80 changes: 78 additions & 2 deletions builds/typescript/client_web/src/components/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Menu } from "lucide-react";
import { CheckCircle2, Menu, X } from "lucide-react";
import { createPortal } from "react-dom";

import { getOnboardingStatus } from "@/api/gateway-adapter";
import { getMemoryUpdateReport, getMemoryUpdateStatus, getOnboardingStatus } from "@/api/gateway-adapter";
import ChatPanel from "@/components/chat/ChatPanel";
import DocumentView from "@/components/document/DocumentView";
import SettingsModal from "@/components/settings/SettingsModal";
Expand Down Expand Up @@ -32,6 +32,12 @@ export default function AppShell({
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [activeFile, setActiveFile] = useState<ProjectFile | null>(null);
const [memoryUpdateNotice, setMemoryUpdateNotice] = useState<{
migrationId: string;
report: string;
hasDeferred: boolean;
} | null>(null);
const [isMemoryUpdateReportOpen, setIsMemoryUpdateReportOpen] = useState(false);
const [mobileHeaderHeight, setMobileHeaderHeight] = useState(0);
const stableAppHeightRef = useRef(0);
const mobileHeaderRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -96,6 +102,33 @@ export default function AppShell({
setActiveFile(null);
}, [selectedProjectId]);

useEffect(() => {
let cancelled = false;
void getMemoryUpdateStatus()
.then(async (status) => {
if (!status.report_path || cancelled) {
return;
}
const storageKey = `braindrive.memoryUpdateReportSeen.${status.migration_id}`;
if (window.localStorage.getItem(storageKey) === "1") {
return;
}
const report = await getMemoryUpdateReport(status.migration_id);
if (cancelled) {
return;
}
setMemoryUpdateNotice({
migrationId: status.migration_id,
report,
hasDeferred: status.deferred_paths.length > 0,
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);

useEffect(() => {
if (!mobileHeaderRef.current) {
return;
Expand Down Expand Up @@ -179,6 +212,14 @@ export default function AppShell({
setActiveFile(null);
}

function dismissMemoryUpdateNotice() {
if (memoryUpdateNotice) {
window.localStorage.setItem(`braindrive.memoryUpdateReportSeen.${memoryUpdateNotice.migrationId}`, "1");
}
setMemoryUpdateNotice(null);
setIsMemoryUpdateReportOpen(false);
}

const documentContent = activeFile && selectedProject ? (
<DocumentView
projectId={selectedProject.id}
Expand Down Expand Up @@ -299,6 +340,41 @@ export default function AppShell({
) : null}

<main className="flex min-w-0 flex-1 flex-col overflow-hidden bg-bd-bg-primary" style={appShellVars}>
{memoryUpdateNotice ? (
<div className="border-b border-bd-border bg-bd-bg-secondary px-4 py-3 md:px-5">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" strokeWidth={1.8} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-bd-text-primary">BrainDrive is up to date.</p>
<p className="mt-0.5 text-xs leading-5 text-bd-text-secondary">
{memoryUpdateNotice.hasDeferred
? "Safe memory updates were applied. One item was left unchanged because it has custom content."
: "Memory instructions were updated so the latest features work correctly."}
</p>
<button
type="button"
onClick={() => setIsMemoryUpdateReportOpen((current) => !current)}
className="mt-2 text-xs font-medium text-bd-amber hover:text-bd-amber-hover"
>
{isMemoryUpdateReportOpen ? "Hide details" : "View details"}
</button>
{isMemoryUpdateReportOpen ? (
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-md border border-bd-border bg-bd-bg-primary p-3 text-xs leading-5 text-bd-text-secondary">
{memoryUpdateNotice.report}
</pre>
) : null}
</div>
<button
type="button"
aria-label="Dismiss memory update notice"
onClick={dismissMemoryUpdateNotice}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-bd-text-muted hover:bg-bd-bg-hover hover:text-bd-text-primary"
>
<X size={15} strokeWidth={1.7} />
</button>
</div>
</div>
) : null}
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden pt-[var(--mobile-header-height)] md:pt-0"
>
Expand Down
7 changes: 4 additions & 3 deletions builds/typescript/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,10 @@ function applyAdapterEnvironmentOverrides(config: AdapterConfig): AdapterConfig
}

export async function ensureMemoryLayout(rootDir: string, memoryRoot: string): Promise<void> {
const summary = await initializeMemoryLayout(rootDir, memoryRoot, {
seedDefaultProjects: true,
});
const summary = await initializeMemoryLayout(rootDir, memoryRoot, {
seedDefaultProjects: true,
seedStarterSkills: false,
});
auditLog("memory.init", {
memory_root: memoryRoot,
profile: summary.profile,
Expand Down
89 changes: 84 additions & 5 deletions builds/typescript/gateway/auth-routes.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,14 @@ vi.mock("../tools.js", () => ({
discoverTools: vi.fn(async () => []),
}));

vi.mock("../git.js", () => ({
ensureGitReady: vi.fn(async () => {}),
}));
vi.mock("../git.js", () => ({
ensureGitReady: vi.fn(async () => {}),
commitMemoryChange: vi.fn(async () => {}),
exportMemoryArchive: vi.fn(async (_memoryRoot: string, destinationPath: string) => {
await mkdir(path.dirname(destinationPath), { recursive: true });
await writeFile(destinationPath, "backup", "utf8");
}),
}));

vi.mock("../secrets/resolver.js", () => ({
resolveProviderCredentialForStartup: vi.fn(async () => null),
Expand Down Expand Up @@ -126,15 +131,19 @@ async function createTestServer(
deploymentMode?: "managed" | "local";
managedApiBase?: string;
allowManagedPublicAccountProxyRoutes?: boolean;
starterPack?: boolean;
} = {}
): Promise<TestServerContext> {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "paa-auth-int-"));
const memoryRoot = path.join(tempRoot, "memory");
const preferencesRoot = path.join(memoryRoot, "preferences");
const secretsRoot = path.join(tempRoot, "secrets");

await mkdir(preferencesRoot, { recursive: true });
await mkdir(secretsRoot, { recursive: true });
await mkdir(preferencesRoot, { recursive: true });
await mkdir(secretsRoot, { recursive: true });
if (options.starterPack) {
await writeTestStarterPack(tempRoot);
}

mockRuntimeConfig = {
memory_root: memoryRoot,
Expand All @@ -160,8 +169,12 @@ async function createTestServer(
const previousDeploymentMode = process.env.BD_DEPLOYMENT_MODE;
const previousManagedApiBase = process.env.BD_MANAGED_API_BASE;
const previousManagedPublicAccountProxyRoutes = process.env.PAA_MANAGED_PUBLIC_ACCOUNT_PROXY_ROUTES;
const previousMemoryAutoUpdateEnabled = process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED;
const previousAppVersion = process.env.BRAINDRIVE_APP_VERSION;

process.env.PAA_SECRETS_HOME = secretsRoot;
process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED = "false";
process.env.BRAINDRIVE_APP_VERSION = "26.4.20";
if (typeof options.bootstrapToken === "string") {
process.env.PAA_AUTH_BOOTSTRAP_TOKEN = options.bootstrapToken;
} else {
Expand Down Expand Up @@ -217,6 +230,16 @@ async function createTestServer(
} else {
delete process.env.PAA_MANAGED_PUBLIC_ACCOUNT_PROXY_ROUTES;
}
if (typeof previousMemoryAutoUpdateEnabled === "string") {
process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED = previousMemoryAutoUpdateEnabled;
} else {
delete process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED;
}
if (typeof previousAppVersion === "string") {
process.env.BRAINDRIVE_APP_VERSION = previousAppVersion;
} else {
delete process.env.BRAINDRIVE_APP_VERSION;
}
},
};
}
Expand Down Expand Up @@ -250,6 +273,13 @@ function localOwnerAdminHeaders(): Record<string, string> {
}),
};
}

async function writeTestStarterPack(rootDir: string): Promise<void> {
const starterRoot = path.join(rootDir, "memory", "starter-pack");
await mkdir(path.join(starterRoot, "base", "me"), { recursive: true });
await writeFile(path.join(starterRoot, "base", "AGENT.md"), "# BrainDrive Agent\n\nUse current guidance.\n", "utf8");
await writeFile(path.join(starterRoot, "base", "me", "todo.md"), "# My Todos\n\n## Active\n", "utf8");
}

describe.sequential("gateway auth route integration", () => {
let context: TestServerContext | null = null;
Expand Down Expand Up @@ -507,6 +537,55 @@ describe.sequential("gateway auth route integration", () => {
expect(parseJson<{ error: string }>(response.body).error).toBe("support_bundle_requires_local_jwt_auth");
});

it("exposes memory update status, apply, and report endpoints", async () => {
context = await createTestServer({ authMode: "local-owner", starterPack: true });
const memoryRoot = path.join(context.tempRoot, "memory");
await mkdir(memoryRoot, { recursive: true });
await writeFile(path.join(memoryRoot, "AGENT.md"), "# Custom Agent\n\nKeep this.\n", "utf8");

const statusResponse = await context.app.inject({
method: "GET",
url: "/updates/memory/status",
headers: localOwnerAdminHeaders(),
});
expect(statusResponse.statusCode).toBe(200);
const status = parseJson<{ pending: boolean; migration_id: string }>(statusResponse.body);
expect(status.pending).toBe(true);
expect(status.migration_id).toBe("starter-pack-26.4.20");

const planResponse = await context.app.inject({
method: "POST",
url: "/updates/memory/plan",
headers: localOwnerAdminHeaders(),
payload: {},
});
expect(planResponse.statusCode).toBe(200);
const plan = parseJson<{ items: Array<{ path: string; action: string }> }>(planResponse.body);
expect(plan.items.some((item) => item.path === "AGENT.md" && item.action === "defer")).toBe(true);

const applyResponse = await context.app.inject({
method: "POST",
url: "/updates/memory/apply",
headers: localOwnerAdminHeaders(),
payload: {},
});
expect(applyResponse.statusCode).toBe(201);
const applied = parseJson<{ status: string; applied_paths: string[]; deferred_paths: string[]; report_path: string }>(
applyResponse.body
);
expect(applied.status).toBe("partially_applied");
expect(applied.applied_paths).toContain("me/todo.md");
expect(applied.deferred_paths).toContain("AGENT.md");

const reportResponse = await context.app.inject({
method: "GET",
url: `/updates/memory/reports/${status.migration_id}`,
headers: localOwnerAdminHeaders(),
});
expect(reportResponse.statusCode).toBe(200);
expect(reportResponse.body).toContain("BrainDrive Memory Update 26.4.20");
});

it("rejects unauthenticated memory backup settings updates", async () => {
context = await createTestServer();

Expand Down
Loading