Gateway: hide BOOTSTRAP file after onboarding

This commit is contained in:
Gustavo Madeira Santana 2026-02-15 15:18:19 -05:00
parent 3cd786cc2d
commit 994f5fd617
3 changed files with 92 additions and 3 deletions

View File

@ -102,6 +102,11 @@ type WorkspaceOnboardingState = {
onboardingCompletedAt?: string;
};
export type WorkspaceOnboardingStateSnapshot = {
bootstrapSeededAt?: string;
onboardingCompletedAt?: string;
};
/** Set of recognized bootstrap filenames for runtime validation */
const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
DEFAULT_AGENTS_FILENAME,
@ -184,6 +189,24 @@ async function readWorkspaceOnboardingState(statePath: string): Promise<Workspac
}
}
export async function readWorkspaceOnboardingStateForDir(
dir: string,
): Promise<WorkspaceOnboardingStateSnapshot> {
const statePath = resolveWorkspaceStatePath(resolveUserPath(dir));
const state = await readWorkspaceOnboardingState(statePath);
return {
bootstrapSeededAt: state.bootstrapSeededAt,
onboardingCompletedAt: state.onboardingCompletedAt,
};
}
export async function isWorkspaceOnboardingCompleted(dir: string): Promise<boolean> {
const state = await readWorkspaceOnboardingStateForDir(dir);
return (
typeof state.onboardingCompletedAt === "string" && state.onboardingCompletedAt.trim().length > 0
);
}
async function writeWorkspaceOnboardingState(
statePath: string,
state: WorkspaceOnboardingState,

View File

@ -25,6 +25,8 @@ const mocks = vi.hoisted(() => ({
fsAccess: vi.fn(async () => {}),
fsMkdir: vi.fn(async () => undefined),
fsAppendFile: vi.fn(async () => {}),
fsReadFile: vi.fn(async () => ""),
fsStat: vi.fn(async () => null),
}));
vi.mock("../../config/config.js", () => ({
@ -81,6 +83,8 @@ vi.mock("node:fs/promises", async () => {
access: mocks.fsAccess,
mkdir: mocks.fsMkdir,
appendFile: mocks.fsAppendFile,
readFile: mocks.fsReadFile,
stat: mocks.fsStat,
};
return { ...patched, default: patched };
});
@ -109,6 +113,21 @@ function makeCall(method: keyof typeof agentsHandlers, params: Record<string, un
return { respond, promise };
}
function createEnoentError() {
const err = new Error("ENOENT") as NodeJS.ErrnoException;
err.code = "ENOENT";
return err;
}
beforeEach(() => {
mocks.fsReadFile.mockImplementation(async () => {
throw createEnoentError();
});
mocks.fsStat.mockImplementation(async () => {
throw createEnoentError();
});
});
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
@ -371,3 +390,37 @@ describe("agents.delete", () => {
);
});
});
describe("agents.files.list", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfigReturn = {};
});
it("includes BOOTSTRAP.md when onboarding has not completed", async () => {
const { respond, promise } = makeCall("agents.files.list", { agentId: "main" });
await promise;
const [, result] = respond.mock.calls[0] ?? [];
const files = (result as { files: Array<{ name: string }> }).files;
expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(true);
});
it("hides BOOTSTRAP.md when workspace onboarding is complete", async () => {
mocks.fsReadFile.mockImplementation(async (filePath: string | URL | number) => {
if (String(filePath).endsWith("workspace-state.json")) {
return JSON.stringify({
onboardingCompletedAt: "2026-02-15T14:00:00.000Z",
});
}
throw createEnoentError();
});
const { respond, promise } = makeCall("agents.files.list", { agentId: "main" });
await promise;
const [, result] = respond.mock.calls[0] ?? [];
const files = (result as { files: Array<{ name: string }> }).files;
expect(files.some((file) => file.name === "BOOTSTRAP.md")).toBe(false);
});
});

View File

@ -17,6 +17,7 @@ import {
DEFAULT_TOOLS_FILENAME,
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
isWorkspaceOnboardingCompleted,
} from "../../agents/workspace.js";
import { movePathToTrash } from "../../browser/trash.js";
import {
@ -52,6 +53,9 @@ const BOOTSTRAP_FILE_NAMES = [
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
] as const;
const BOOTSTRAP_FILE_NAMES_WITHOUT_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
(name) => name !== DEFAULT_BOOTSTRAP_FILENAME,
);
const MEMORY_FILE_NAMES = [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const;
@ -108,7 +112,7 @@ async function statFile(filePath: string): Promise<FileMeta | null> {
}
}
async function listAgentFiles(workspaceDir: string) {
async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?: boolean }) {
const files: Array<{
name: string;
path: string;
@ -117,7 +121,10 @@ async function listAgentFiles(workspaceDir: string) {
updatedAtMs?: number;
}> = [];
for (const name of BOOTSTRAP_FILE_NAMES) {
const bootstrapFileNames = options?.hideBootstrap
? BOOTSTRAP_FILE_NAMES_WITHOUT_ONBOARDING
: BOOTSTRAP_FILE_NAMES;
for (const name of bootstrapFileNames) {
const filePath = path.join(workspaceDir, name);
const meta = await statFile(filePath);
if (meta) {
@ -417,7 +424,13 @@ export const agentsHandlers: GatewayRequestHandlers = {
return;
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const files = await listAgentFiles(workspaceDir);
let hideBootstrap = false;
try {
hideBootstrap = await isWorkspaceOnboardingCompleted(workspaceDir);
} catch {
// Fall back to showing BOOTSTRAP if workspace state cannot be read.
}
const files = await listAgentFiles(workspaceDir, { hideBootstrap });
respond(true, { agentId, workspace: workspaceDir, files }, undefined);
},
"agents.files.get": async ({ params, respond }) => {