diff --git a/plugins/stewards/server.test.ts b/plugins/stewards/server.test.ts index cc4effc..a2c0557 100644 --- a/plugins/stewards/server.test.ts +++ b/plugins/stewards/server.test.ts @@ -20,7 +20,8 @@ function world() { const repos = [{ dir: "web", url: "https://github.com/acme/web.git" }]; const entities = new Map(); const rpcCalls: Call[] = []; - const threads = new Map(); + const threads = new Map(); + 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(); @@ -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 })), @@ -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()] @@ -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() { @@ -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 }> }> }> }; diff --git a/plugins/stewards/server.ts b/plugins/stewards/server.ts index 7e2b449..f5f2962 100644 --- a/plugins/stewards/server.ts +++ b/plugins/stewards/server.ts @@ -454,7 +454,7 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P async function threadInfo(threadId: string): Promise { try { - const t = (await bb.sdk.threads.get({ threadId })) as unknown as Partial; + const t = (await bb.sdk.threads.get({ threadId })) as unknown as Partial & { environment?: { hostId?: string | null } | null }; return { id: threadId, title: t.title ?? null, @@ -462,7 +462,8 @@ export function createStewardsPlugin(deps: StewardsDeps): (bb: BbPluginApi) => P 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, @@ -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) {