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
36 changes: 33 additions & 3 deletions plugins/stewards/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ function world() {
const repos = [{ dir: "web", url: "https://github.com/acme/web.git" }];
const entities = new Map<string, EntitySnapshot>();
const rpcCalls: Call[] = [];
const threads = new Map<string, { status: string; archivedAt: number | null; title?: string | null; parentThreadId?: string | null; sectionId?: string | null }>();
const threads = new Map<string, { status: string; archivedAt: number | null; title?: string | null; parentThreadId?: string | null; sectionId?: string | null; hostId?: string }>();
const writes: Array<{ path: string; hostId?: string }> = [];
const sections: Array<{ id: string; name: string }> = [];
/** Each thread's latest context window reading, as bb reports it. */
const usage = new Map<string, number>();
Expand Down Expand Up @@ -66,6 +67,13 @@ function world() {
],
}),
},
files: {
mkdir: async () => ({}),
write: async (args: { path: string; hostId?: string }) => {
writes.push({ path: args.path, hostId: args.hostId });
return { outcome: "written" };
},
},
hosts: { list: async () => [{ id: "host_off", status: "disconnected" }, { id: "host_on", status: "connected" }] },
threadSections: {
list: async () => sections.map((s) => ({ ...s })),
Expand Down Expand Up @@ -95,7 +103,10 @@ function world() {
get: async ({ threadId }: { threadId: string }) => {
const t = threads.get(threadId);
if (t === undefined) throw Object.assign(new Error("not found"), { status: 404 });
return makeThreadResponse({ id: threadId, title: t.title ?? null, status: t.status as never, archivedAt: t.archivedAt, environmentId: "env1", providerId: "claude-code", parentThreadId: t.parentThreadId ?? null, sectionId: t.sectionId ?? null });
return {
...makeThreadResponse({ id: threadId, title: t.title ?? null, status: t.status as never, archivedAt: t.archivedAt, environmentId: "env1", providerId: "claude-code", parentThreadId: t.parentThreadId ?? null, sectionId: t.sectionId ?? null }),
...(t.hostId === undefined ? {} : { environment: { hostId: t.hostId } }),
};
},
list: async ({ sectionId, archived }: { sectionId?: string; archived?: boolean }) =>
[...threads.entries()]
Expand Down Expand Up @@ -134,7 +145,7 @@ function world() {
},
};
let now = Date.parse("2026-10-14T15:00:00Z");
return { files, repos, entities, rpcCalls, threads, sections, usage, metadata, spawns, sends, userTurns, sdk, now: () => now, advance: (ms: number) => (now += ms) };
return { files, writes, repos, entities, rpcCalls, threads, sections, usage, metadata, spawns, sends, userTurns, sdk, now: () => now, advance: (ms: number) => (now += ms) };
}

async function setup() {
Expand Down Expand Up @@ -1029,6 +1040,25 @@ describe("passes", () => {
});
});

describe("bb stewards init", () => {
it("writes on the thread's own machine", async () => {
const t = await setup();
t.threads.set("thr_me", { status: "active", archivedAt: null, hostId: "host_me" });
const out = await t.harness.behavior.runCli(["init", "--teams", "eng"], { threadId: "thr_me", projectId: "p1", cwd: "/w" });
expect(out.exitCode).toBe(0);
expect(t.writes).toContainEqual({ path: `/w/.bb/${PROJECT_FILE}`, hostId: "host_me" });
expect(t.writes.every((w) => w.hostId === "host_me")).toBe(true);
});

it("writes on the project's machine when the thread names none, never on the server's own", async () => {
const t = await setup();
t.threads.set("thr_me", { status: "active", archivedAt: null });
await t.harness.behavior.runCli(["init", "--teams", "ENG"], { threadId: "thr_me", projectId: "p1", cwd: "/w" });
expect(t.writes.length).toBe(4);
expect(t.writes.every((w) => w.hostId === "host_on")).toBe(true);
});
});

describe("the settings page", () => {
type Overview = { paused: boolean; projects: Array<{ name: string; configured: boolean; repos: string[]; playbooks: Array<{ kind: string; version: string | null }>; problems: string[]; projectStewardThreadId: string | null; stewards: Array<{ key: string; summary: string | null; actor: string | null; escalations: Array<{ route: string; question: string }> }> }> };

Expand Down
12 changes: 9 additions & 3 deletions plugins/stewards/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,15 +454,16 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P

async function threadInfo(threadId: string): Promise<ThreadInfo | null> {
try {
const t = (await bb.sdk.threads.get({ threadId })) as unknown as Partial<ThreadInfo>;
const t = (await bb.sdk.threads.get({ threadId })) as unknown as Partial<ThreadInfo> & { environment?: { hostId?: string | null } | null };
return {
id: threadId,
title: t.title ?? null,
status: String(t.status ?? "idle"),
archivedAt: t.archivedAt ?? null,
deletedAt: t.deletedAt ?? null,
environmentId: t.environmentId ?? null,
environmentHostId: t.environmentHostId ?? null,
// The SDK's thread carries its machine on `environment`, not at the top level.
environmentHostId: t.environment?.hostId ?? t.environmentHostId ?? null,
providerId: String(t.providerId ?? ""),
parentThreadId: t.parentThreadId ?? null,
sectionId: t.sectionId ?? null,
Expand Down Expand Up @@ -2032,7 +2033,12 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P
}
const dir = input.options.dir ?? (ctx.cwd === undefined ? undefined : `${ctx.cwd.replace(/\/$/u, "")}/.bb`);
if (dir === undefined) throw new PluginCliError("No directory: run from a thread or pass --dir.", { code: "no_dir" });
const hostId = ctx.threadId === undefined ? undefined : ((await threadInfo(ctx.threadId))?.environmentHostId ?? undefined);
// Write on the thread's machine, else the project's: left to core, the
// write lands on the server's own daemon, which bb-gate's servers lack.
const hostId =
(ctx.threadId === undefined ? null : ((await threadInfo(ctx.threadId))?.environmentHostId ?? null)) ??
(ctx.projectId === undefined ? null : await projectHost(ctx.projectId)) ??
undefined;
const files: Array<[string, string]> = [[PROJECT_FILE, projectFileTemplate(teams)], ...Object.entries(PLAYBOOK_TEMPLATES).map(([name, text]) => [playbookPath(name), text] as [string, string])];
const lines: string[] = [];
for (const [rel, content] of files) {
Expand Down
Loading