diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ee1052a1530..e739b4b44df 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -86,6 +86,7 @@ "@superset/db": "workspace:*", "@superset/desktop-mcp": "workspace:*", "@superset/host-service": "workspace:*", + "@superset/launch-context": "workspace:*", "@superset/local-db": "workspace:*", "@superset/macos-process-metrics": "workspace:*", "@superset/panes": "workspace:*", diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts index ee67e8a95fb..bc48c2cf0c5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -8,22 +8,22 @@ import { indexResolvedAgentConfigs, type ResolvedAgentConfig, } from "@superset/shared/agent-settings"; +import { + type AgentLaunchSpec, + type AttachmentFile, + buildLaunchContext, + buildLaunchSpec, + type ContentPart, + defaultContributorRegistry, + type LaunchSource, + type ResolveCtx, +} from "@superset/launch-context"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import type { PendingChatLaunch, PendingTerminalLaunch, PendingWorkspaceRow, } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; -import { buildLaunchSpec } from "shared/context/buildLaunchSpec"; -import { buildLaunchContext } from "shared/context/composer"; -import { defaultContributorRegistry } from "shared/context/contributors"; -import type { - AgentLaunchSpec, - AttachmentFile, - ContentPart, - LaunchSource, - ResolveCtx, -} from "shared/context/types"; export interface LoadedAttachment { data: string; // base64 data URL diff --git a/bun.lock b/bun.lock index e0b284a1dc3..f9cd1267868 100644 --- a/bun.lock +++ b/bun.lock @@ -164,6 +164,7 @@ "@superset/db": "workspace:*", "@superset/desktop-mcp": "workspace:*", "@superset/host-service": "workspace:*", + "@superset/launch-context": "workspace:*", "@superset/local-db": "workspace:*", "@superset/macos-process-metrics": "workspace:*", "@superset/panes": "workspace:*", @@ -773,6 +774,7 @@ "@mastra/core": "1.26.0-alpha.3", "@octokit/rest": "^22.0.1", "@superset/chat": "workspace:*", + "@superset/launch-context": "workspace:*", "@superset/port-scanner": "workspace:*", "@superset/pty-daemon": "workspace:*", "@superset/shared": "workspace:*", @@ -807,6 +809,18 @@ "@anush008/tokenizers-linux-arm64-gnu": "0.6.0", }, }, + "packages/launch-context": { + "name": "@superset/launch-context", + "version": "0.1.0", + "dependencies": { + "@superset/shared": "workspace:*", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "bun-types": "^1.3.1", + "typescript": "^5.9.3", + }, + }, "packages/local-db": { "name": "@superset/local-db", "version": "0.1.0", @@ -2619,6 +2633,8 @@ "@superset/host-service": ["@superset/host-service@workspace:packages/host-service"], + "@superset/launch-context": ["@superset/launch-context@workspace:packages/launch-context"], + "@superset/local-db": ["@superset/local-db@workspace:packages/local-db"], "@superset/macos-process-metrics": ["@superset/macos-process-metrics@workspace:packages/macos-process-metrics"], diff --git a/packages/host-service/package.json b/packages/host-service/package.json index 0ea876c7e00..7a076512e08 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -59,6 +59,7 @@ "@mastra/core": "1.26.0-alpha.3", "@octokit/rest": "^22.0.1", "@superset/chat": "workspace:*", + "@superset/launch-context": "workspace:*", "@superset/port-scanner": "workspace:*", "@superset/pty-daemon": "workspace:*", "@superset/shared": "workspace:*", diff --git a/packages/host-service/src/trpc/router/attachments/storage.ts b/packages/host-service/src/trpc/router/attachments/storage.ts index e351da58350..09b70daffea 100644 --- a/packages/host-service/src/trpc/router/attachments/storage.ts +++ b/packages/host-service/src/trpc/router/attachments/storage.ts @@ -1,4 +1,4 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import mimeTypes from "mime-types"; @@ -89,3 +89,25 @@ export function deleteAttachment( const dir = getAttachmentDir(attachmentId, baseDirOverride); rmSync(dir, { recursive: true, force: true }); } + +/** + * Reads bytes + metadata for a stored attachment. Throws if the + * attachment dir / metadata.json / bytes file is missing — callers + * should treat that as "attachment was deleted" and degrade + * gracefully (e.g. skip writing it to the worktree). + */ +export function readAttachment( + attachmentId: string, + baseDirOverride?: string, +): { bytes: Uint8Array; metadata: AttachmentMetadata } { + const metaPath = getAttachmentMetadataPath(attachmentId, baseDirOverride); + const metadata = JSON.parse( + readFileSync(metaPath, "utf-8"), + ) as AttachmentMetadata; + const filePath = getAttachmentFilePath( + attachmentId, + metadata.mediaType, + baseDirOverride, + ); + return { bytes: new Uint8Array(readFileSync(filePath)), metadata }; +} diff --git a/packages/host-service/src/trpc/router/settings/agent-configs.test.ts b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts index 6561aa5998b..fcf88a2a44d 100644 --- a/packages/host-service/src/trpc/router/settings/agent-configs.test.ts +++ b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts @@ -3,9 +3,13 @@ import { describe, expect, it } from "bun:test"; import { resolve } from "node:path"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import type { HostDb } from "../../../db"; import * as schema from "../../../db/schema"; import type { HostServiceContext } from "../../../types"; -import { agentConfigsRouter } from "./agent-configs"; +import { + agentConfigsRouter, + findOrCreateHostPresetByPresetId, +} from "./agent-configs"; import { AGENT_PRESETS } from "./agent-presets"; const MIGRATIONS_FOLDER = resolve(import.meta.dir, "../../../../drizzle"); @@ -60,6 +64,14 @@ describe("agentConfigsRouter", () => { expect(second.map((row) => row.id)).toEqual(first.map((row) => row.id)); }); + it("seeds mastracode as a stdin-transport agent", async () => { + const caller = createCaller(); + await caller.list(); + const masta = await caller.add({ presetId: "mastracode" }); + expect(masta.promptTransport).toBe("stdin"); + expect(masta.promptArgs).toEqual([]); + }); + it("returns rows in displayOrder", async () => { const caller = createCaller(); const seeded = await caller.list(); @@ -268,4 +280,29 @@ describe("agentConfigsRouter", () => { expect(result.find((row) => row.presetId === "pi")).toBeUndefined(); }); }); + + describe("findOrCreateHostPresetByPresetId()", () => { + it("returns the existing row for a default-seeded preset", () => { + const db = createTestDb() as unknown as HostDb; + const claude = findOrCreateHostPresetByPresetId(db, "claude"); + expect(claude?.presetId).toBe("claude"); + }); + + it("materializes a row for a non-default builtin preset on first call", () => { + const db = createTestDb() as unknown as HostDb; + const masta = findOrCreateHostPresetByPresetId(db, "mastracode"); + expect(masta?.presetId).toBe("mastracode"); + expect(masta?.promptTransport).toBe("stdin"); + + // Calling again returns the same row + const again = findOrCreateHostPresetByPresetId(db, "mastracode"); + expect(again?.id).toBe(masta?.id); + }); + + it("returns null for an unknown presetId", () => { + const db = createTestDb() as unknown as HostDb; + const result = findOrCreateHostPresetByPresetId(db, "nonexistent"); + expect(result).toBeNull(); + }); + }); }); diff --git a/packages/host-service/src/trpc/router/settings/agent-configs.ts b/packages/host-service/src/trpc/router/settings/agent-configs.ts index 278a54b06fd..5008f63e3d6 100644 --- a/packages/host-service/src/trpc/router/settings/agent-configs.ts +++ b/packages/host-service/src/trpc/router/settings/agent-configs.ts @@ -132,6 +132,43 @@ function seedDefaultsIfEmpty(db: HostDb): HostAgentConfigRow[] { return listOrdered(db); } +/** + * Find an agent config row by `presetId`, seeding defaults first if + * the table is empty and creating the row from the preset definition + * if the user picked a builtin that wasn't part of the default seed + * set (e.g. mastracode, opencode, pi). Returns null only when + * `presetId` doesn't match any known preset. + * + * Used by the launches/ wiring in `create.ts`: a user picking any of + * the 9 builtin agents gets a row materialized on first use. + */ +export function findOrCreateHostPresetByPresetId( + db: HostDb, + presetId: string, +): HostAgentConfigOutput | null { + const seeded = seedDefaultsIfEmpty(db); + const existing = seeded.find((row) => row.presetId === presetId); + if (existing) return toOutput(existing); + + const preset = getPresetById(presetId); + if (!preset) return null; + + const nextOrder = + seeded.length === 0 + ? 0 + : Math.max(...seeded.map((row) => row.displayOrder)) + 1; + const insert = rowFromPreset(preset, nextOrder); + db.insert(hostAgentConfigs).values(insert).run(); + const created = db + .select() + .from(hostAgentConfigs) + .where(eq(hostAgentConfigs.id, insert.id)) + .get(); + return created ? toOutput(created) : null; +} + +export type { HostAgentConfigOutput }; + const updatePatchSchema = z .object({ label: z.string().trim().min(1).optional(), diff --git a/packages/host-service/src/trpc/router/settings/agent-presets.ts b/packages/host-service/src/trpc/router/settings/agent-presets.ts index 2ad480ced1c..c32021346f3 100644 --- a/packages/host-service/src/trpc/router/settings/agent-presets.ts +++ b/packages/host-service/src/trpc/router/settings/agent-presets.ts @@ -91,8 +91,8 @@ export const AGENT_PRESETS = [ "Mastra's coding agent for building, debugging, and shipping code from the terminal.", command: "mastracode", args: [], - promptTransport: "argv", - promptArgs: ["--prompt"], + promptTransport: "stdin", + promptArgs: [], env: {}, }, { diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts index d9853b69d76..7983649ea8c 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts @@ -9,16 +9,25 @@ import { resolveDefaultBranchName, resolveUpstream, } from "../../../../runtime/git/refs"; +import type { HostServiceContext } from "../../../../types"; import { protectedProcedure } from "../../../index"; import { gitConfigWrite } from "../../git/utils/config-write"; import { ensureMainWorkspace } from "../../project/utils/ensure-main-workspace"; +import { findOrCreateHostPresetByPresetId } from "../../settings/agent-configs"; import { createInputSchema } from "../schemas"; import { enablePushAutoSetupRemote } from "../shared/git-config"; +import { + buildAgentLaunch, + buildHostResolveCtx, + resolveAttachmentFiles, + startTerminalLaunch, + writeAttachmentsToWorktree, +} from "../shared/launches"; import { requireLocalProject } from "../shared/local-project"; import { clearProgress, setProgress } from "../shared/progress-store"; import { startSetupTerminalIfPresent } from "../shared/setup-terminal"; import { buildStartPointFromHint } from "../shared/start-point"; -import type { TerminalDescriptor } from "../shared/types"; +import type { LaunchDescriptor, TerminalDescriptor } from "../shared/types"; import { safeResolveWorktreePath } from "../shared/worktree-paths"; import { applyAiWorkspaceRename } from "../utils/ai-workspace-names"; import { listBranchNames } from "../utils/list-branch-names"; @@ -333,6 +342,7 @@ export const create = protectedProcedure } const terminals: TerminalDescriptor[] = []; + const launches: LaunchDescriptor[] = []; const warnings: string[] = []; if (input.composer.runSetupScript) { @@ -349,14 +359,118 @@ export const create = protectedProcedure } } + // PR4: agent launch. Optional — runs only when the renderer + // passed `composer.agentId` (the picker selected an agent). + // Failures here surface as warnings rather than aborting create + // — the workspace is already on disk and registered, so a + // missing preset / spawn failure shouldn't unwind that work. + if (input.composer.agentId) { + try { + await runAgentLaunch({ + ctx, + workspaceId: cloudRow.id, + projectId: input.projectId, + worktreePath, + agentPresetId: input.composer.agentId, + prompt: input.composer.prompt, + githubIssueUrls: input.linkedContext?.githubIssueUrls ?? [], + internalTaskIds: input.linkedContext?.internalIssueIds ?? [], + linkedPrUrl: input.linkedContext?.linkedPrUrl, + attachmentIds: input.linkedContext?.attachmentIds ?? [], + launches, + warnings, + }); + } catch (err) { + console.warn( + "[workspaceCreation.create] agent launch failed; continuing without", + err, + ); + warnings.push( + `Agent launch failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + clearProgress(input.pendingId); return { workspace: cloudRow, terminals, + launches, warnings, }; } finally { clearProgress(input.pendingId); } }); + +interface RunAgentLaunchInput { + ctx: HostServiceContext; + workspaceId: string; + projectId: string; + worktreePath: string; + agentPresetId: string; + prompt?: string; + githubIssueUrls: string[]; + internalTaskIds: string[]; + linkedPrUrl?: string; + attachmentIds: string[]; + launches: LaunchDescriptor[]; + warnings: string[]; +} + +/** + * Resolves the host preset row, builds an agent launch plan, writes + * any attachments to the worktree, and spawns the terminal session. + * Pushes the resulting terminalId+label onto `launches`. Soft-fails: + * an unknown presetId or empty plan logs a warning and returns + * without throwing — the workspace is already created and shouldn't + * unwind on a launch-only failure. + */ +async function runAgentLaunch(input: RunAgentLaunchInput): Promise { + const presetRow = findOrCreateHostPresetByPresetId( + input.ctx.db, + input.agentPresetId, + ); + if (!presetRow) { + input.warnings.push(`Unknown agentId: ${input.agentPresetId}`); + return; + } + + const attachments = resolveAttachmentFiles(input.attachmentIds); + const resolveCtx = buildHostResolveCtx({ + ctx: input.ctx, + projectId: input.projectId, + githubIssueUrls: input.githubIssueUrls, + linkedPrUrl: input.linkedPrUrl, + }); + + const plan = await buildAgentLaunch({ + projectId: input.projectId, + preset: presetRow, + prompt: input.prompt, + internalTaskIds: input.internalTaskIds, + githubIssueUrls: input.githubIssueUrls, + linkedPrUrl: input.linkedPrUrl, + attachments, + resolveCtx, + }); + if (!plan) return; + + writeAttachmentsToWorktree(input.worktreePath, plan.attachmentsToWrite); + + const result = await startTerminalLaunch({ + ctx: input.ctx, + workspaceId: input.workspaceId, + plan, + }); + if ("error" in result) { + input.warnings.push(`Failed to start agent terminal: ${result.error}`); + return; + } + input.launches.push({ + kind: "terminal", + terminalId: result.terminalId, + label: result.label, + }); +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts index 99b2f163e57..831429ef3de 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-issue-content.ts @@ -1,8 +1,7 @@ import { TRPCError } from "@trpc/server"; import { protectedProcedure } from "../../../index"; -import { githubIssueContentInputSchema, issueContentSchema } from "../schemas"; -import { resolveGithubRepo } from "../shared/project-helpers"; -import { execGh } from "../utils/exec-gh"; +import { githubIssueContentInputSchema } from "../schemas"; +import { fetchGithubIssueContent } from "../shared/github-content"; // Shell out to the user's `gh` CLI rather than host-service's // octokit — `gh auth login` works out of the box while the @@ -11,28 +10,12 @@ import { execGh } from "../utils/exec-gh"; export const getGitHubIssueContent = protectedProcedure .input(githubIssueContentInputSchema) .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); try { - const raw = await execGh([ - "issue", - "view", - String(input.issueNumber), - "--repo", - `${repo.owner}/${repo.name}`, - "--json", - "number,title,body,url,state,author,createdAt,updatedAt", - ]); - const data = issueContentSchema.parse(raw); - return { - number: data.number, - title: data.title, - body: data.body ?? "", - url: data.url, - state: data.state.toLowerCase(), - author: data.author?.login ?? null, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }; + return await fetchGithubIssueContent( + ctx, + input.projectId, + input.issueNumber, + ); } catch (err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts index 30b16121933..383d3d3b786 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/procedures/get-github-pull-request-content.ts @@ -1,92 +1,21 @@ import { TRPCError } from "@trpc/server"; import { protectedProcedure } from "../../../index"; -import { - githubPullRequestContentInputSchema, - pullRequestContentSchema, -} from "../schemas"; -import { resolveGithubRepo } from "../shared/project-helpers"; -import { execGh } from "../utils/exec-gh"; - -type PullRequestContent = { - number: number; - title: string; - body: string; - url: string; - state: string; - branch: string; - baseBranch: string; - headRepositoryOwner: string | null; - isCrossRepository: boolean; - author: string | null; - isDraft: boolean; - createdAt: string | undefined; - updatedAt: string | undefined; -}; - -// Browsing the PR list re-opens the detail panel constantly; cache the -// `gh pr view` response so we don't burn the user's GitHub token bucket on -// repeat clicks. Concurrent callers share the same in-flight promise. -const PULL_REQUEST_CONTENT_CACHE_TTL_MS = 30_000; -const pullRequestContentCache = new Map< - string, - { promise: Promise; fetchedAt: number } ->(); +import { githubPullRequestContentInputSchema } from "../schemas"; +import { fetchGithubPullRequestContent } from "../shared/github-content"; export const getGitHubPullRequestContent = protectedProcedure .input(githubPullRequestContentInputSchema) .query(async ({ ctx, input }) => { - const repo = await resolveGithubRepo(ctx, input.projectId); - const cacheKey = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}#${input.prNumber}`; - const cached = pullRequestContentCache.get(cacheKey); - if ( - cached && - Date.now() - cached.fetchedAt < PULL_REQUEST_CONTENT_CACHE_TTL_MS - ) { - return cached.promise; + try { + return await fetchGithubPullRequestContent( + ctx, + input.projectId, + input.prNumber, + ); + } catch (err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to fetch PR #${input.prNumber}: ${err instanceof Error ? err.message : String(err)}`, + }); } - - const fetchedAt = Date.now(); - const promise = (async (): Promise => { - try { - const raw = await execGh([ - "pr", - "view", - String(input.prNumber), - "--repo", - `${repo.owner}/${repo.name}`, - "--json", - "number,title,body,url,state,author,headRefName,baseRefName,headRepositoryOwner,isCrossRepository,isDraft,createdAt,updatedAt", - ]); - const data = pullRequestContentSchema.parse(raw); - return { - number: data.number, - title: data.title, - body: data.body ?? "", - url: data.url, - state: data.state.toLowerCase(), - branch: data.headRefName, - baseBranch: data.baseRefName, - headRepositoryOwner: data.headRepositoryOwner?.login ?? null, - isCrossRepository: data.isCrossRepository, - author: data.author?.login ?? null, - isDraft: data.isDraft, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }; - } catch (err) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Failed to fetch PR #${input.prNumber}: ${err instanceof Error ? err.message : String(err)}`, - }); - } - })(); - // Evict on failure so the next caller retries instead of replaying the - // same error for the rest of the TTL. - promise.catch(() => { - if (pullRequestContentCache.get(cacheKey)?.promise === promise) { - pullRequestContentCache.delete(cacheKey); - } - }); - pullRequestContentCache.set(cacheKey, { promise, fetchedAt }); - return promise; }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts index 7c98376f7bf..015b87c2e64 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/schemas.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/schemas.ts @@ -12,6 +12,10 @@ const linkedContextSchema = z githubIssueUrls: z.array(z.string()).optional(), linkedPrUrl: z.string().optional(), attachments: z.array(attachmentSchema).optional(), + // PR4: ids into the host attachment store. Replaces the inline + // base64 `attachments` field above (kept for one release for + // backwards-compat — drop in PR 5). + attachmentIds: z.array(z.string()).optional(), }) .optional(); @@ -56,6 +60,12 @@ export const createInputSchema = z.object({ // `resolve-start-point.ts` for the fallback semantics. baseBranchSource: z.enum(["local", "remote-tracking"]).optional(), runSetupScript: z.boolean().optional(), + // PR4: presetId of the host_agent_configs row to launch in the + // new workspace. When set, host-service builds an + // AgentLaunchSpec, writes attachments, and spawns the agent + // terminal as part of create. Returned in `launches[]` on the + // response. + agentId: z.string().optional(), }), linkedContext: linkedContextSchema, }); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/github-content.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/github-content.ts new file mode 100644 index 00000000000..a9b9bdd50ac --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/github-content.ts @@ -0,0 +1,128 @@ +import type { HostServiceContext } from "../../../../types"; +import { + issueContentSchema, + pullRequestContentSchema, +} from "../schemas"; +import { execGh } from "../utils/exec-gh"; +import { resolveGithubRepo } from "./project-helpers"; + +export interface IssueContent { + number: number; + title: string; + body: string; + url: string; + state: string; + author: string | null; + createdAt: string | undefined; + updatedAt: string | undefined; +} + +export interface PullRequestContent { + number: number; + title: string; + body: string; + url: string; + state: string; + branch: string; + baseBranch: string; + headRepositoryOwner: string | null; + isCrossRepository: boolean; + author: string | null; + isDraft: boolean; + createdAt: string | undefined; + updatedAt: string | undefined; +} + +const PR_CONTENT_CACHE_TTL_MS = 30_000; +const prContentCache = new Map< + string, + { promise: Promise; fetchedAt: number } +>(); + +/** + * Shared `gh issue view` fetcher. Used by the issue tRPC procedure + * and by the launches/ host-resolve-ctx so the launch builder can + * inline issue bodies into the agent prompt without going through + * tRPC. + */ +export async function fetchGithubIssueContent( + ctx: HostServiceContext, + projectId: string, + issueNumber: number, +): Promise { + const repo = await resolveGithubRepo(ctx, projectId); + const raw = await execGh([ + "issue", + "view", + String(issueNumber), + "--repo", + `${repo.owner}/${repo.name}`, + "--json", + "number,title,body,url,state,author,createdAt,updatedAt", + ]); + const data = issueContentSchema.parse(raw); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.url, + state: data.state.toLowerCase(), + author: data.author?.login ?? null, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + }; +} + +/** + * Shared `gh pr view` fetcher with a 30s in-memory cache so repeat + * picker clicks don't burn the user's GitHub token bucket. Cache + * evicts on failure. + */ +export async function fetchGithubPullRequestContent( + ctx: HostServiceContext, + projectId: string, + prNumber: number, +): Promise { + const repo = await resolveGithubRepo(ctx, projectId); + const cacheKey = `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}#${prNumber}`; + const cached = prContentCache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < PR_CONTENT_CACHE_TTL_MS) { + return cached.promise; + } + + const fetchedAt = Date.now(); + const promise = (async (): Promise => { + const raw = await execGh([ + "pr", + "view", + String(prNumber), + "--repo", + `${repo.owner}/${repo.name}`, + "--json", + "number,title,body,url,state,author,headRefName,baseRefName,headRepositoryOwner,isCrossRepository,isDraft,createdAt,updatedAt", + ]); + const data = pullRequestContentSchema.parse(raw); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.url, + state: data.state.toLowerCase(), + branch: data.headRefName, + baseBranch: data.baseRefName, + headRepositoryOwner: data.headRepositoryOwner?.login ?? null, + isCrossRepository: data.isCrossRepository, + author: data.author?.login ?? null, + isDraft: data.isDraft, + createdAt: data.createdAt, + updatedAt: data.updatedAt, + }; + })(); + promise.catch(() => { + if (prContentCache.get(cacheKey)?.promise === promise) { + prContentCache.delete(cacheKey); + } + }); + prContentCache.set(cacheKey, { promise, fetchedAt }); + return promise; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/build-agent-launch.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/build-agent-launch.test.ts new file mode 100644 index 00000000000..0558f60bde0 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/build-agent-launch.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "bun:test"; +import type { ResolveCtx } from "@superset/launch-context"; +import { + buildAgentLaunch, + type HostAgentPresetRow, +} from "./build-agent-launch"; + +const stubResolveCtx: ResolveCtx = { + projectId: "p1", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("not used in this test"); + }, + fetchPullRequest: async () => { + throw new Error("not used in this test"); + }, + fetchInternalTask: async () => { + throw new Error("not used in this test"); + }, +}; + +const claudePreset: HostAgentPresetRow = { + presetId: "claude", + label: "Claude", + command: "claude", + args: ["--permission-mode", "acceptEdits"], + promptTransport: "argv", + promptArgs: [], + env: {}, +}; + +const codexPreset: HostAgentPresetRow = { + presetId: "codex", + label: "Codex", + command: "codex", + args: ["--sandbox", "workspace-write"], + promptTransport: "argv", + promptArgs: ["--"], + env: {}, +}; + +const mastracodePreset: HostAgentPresetRow = { + presetId: "mastracode", + label: "Mastracode", + command: "mastracode", + args: [], + promptTransport: "stdin", + promptArgs: [], + env: {}, +}; + +describe("buildAgentLaunch", () => { + it("returns null when there are no sources", async () => { + const plan = await buildAgentLaunch({ + projectId: "p1", + preset: claudePreset, + internalTaskIds: [], + githubIssueUrls: [], + attachments: [], + resolveCtx: stubResolveCtx, + }); + expect(plan).toBeNull(); + }); + + it("composes argv with prompt appended for argv-transport agents", async () => { + const plan = await buildAgentLaunch({ + projectId: "p1", + preset: claudePreset, + prompt: "fix the failing test", + internalTaskIds: [], + githubIssueUrls: [], + attachments: [], + resolveCtx: stubResolveCtx, + }); + expect(plan).not.toBeNull(); + expect(plan?.spawn.command).toBe("claude"); + expect(plan?.spawn.args).toEqual([ + "--permission-mode", + "acceptEdits", + "fix the failing test", + ]); + expect(plan?.stdinPrompt).toBeUndefined(); + }); + + it("inserts promptArgs between args and prompt (codex --)", async () => { + const plan = await buildAgentLaunch({ + projectId: "p1", + preset: codexPreset, + prompt: "do the thing", + internalTaskIds: [], + githubIssueUrls: [], + attachments: [], + resolveCtx: stubResolveCtx, + }); + expect(plan?.spawn.args).toEqual([ + "--sandbox", + "workspace-write", + "--", + "do the thing", + ]); + }); + + it("returns prompt as stdinPrompt for stdin-transport agents", async () => { + const plan = await buildAgentLaunch({ + projectId: "p1", + preset: mastracodePreset, + prompt: "implement feature X", + internalTaskIds: [], + githubIssueUrls: [], + attachments: [], + resolveCtx: stubResolveCtx, + }); + expect(plan?.spawn.command).toBe("mastracode"); + expect(plan?.spawn.args).toEqual([]); + expect(plan?.stdinPrompt).toBe("implement feature X"); + }); + + it("omits prompt entirely when there is no user prompt and no other sources", async () => { + // claudePreset alone with no prompt → no sources → null + const plan = await buildAgentLaunch({ + projectId: "p1", + preset: claudePreset, + internalTaskIds: [], + githubIssueUrls: [], + attachments: [], + resolveCtx: stubResolveCtx, + }); + expect(plan).toBeNull(); + }); + + it("emits attachmentsToWrite for non-image file attachments preserving filenames", async () => { + const plan = await buildAgentLaunch({ + projectId: "p1", + preset: claudePreset, + prompt: "look at the diff", + internalTaskIds: [], + githubIssueUrls: [], + attachments: [ + { + filename: "diff.patch", + mediaType: "text/x-diff", + data: new Uint8Array([1, 2, 3]), + }, + ], + resolveCtx: stubResolveCtx, + }); + expect(plan?.attachmentsToWrite.map((a) => a.filename)).toEqual([ + "diff.patch", + ]); + }); + + // Image attachments lose their filename through the launch-context + // attachment contributor (image parts don't carry filenames). Falls + // back to `attachment_N` numbering — matches renderer behavior. + it("falls back to attachment_N for image attachments", async () => { + const plan = await buildAgentLaunch({ + projectId: "p1", + preset: claudePreset, + prompt: "look at the screenshot", + internalTaskIds: [], + githubIssueUrls: [], + attachments: [ + { + filename: "screenshot.png", + mediaType: "image/png", + data: new Uint8Array([1, 2, 3]), + }, + ], + resolveCtx: stubResolveCtx, + }); + expect(plan?.attachmentsToWrite.map((a) => a.filename)).toEqual([ + "attachment_1", + ]); + }); +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/build-agent-launch.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/build-agent-launch.ts new file mode 100644 index 00000000000..899ba27d03d --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/build-agent-launch.ts @@ -0,0 +1,263 @@ +import type { AgentDefinitionId } from "@superset/shared/agent-catalog"; +import { + type AttachmentFile, + buildLaunchContext, + buildLaunchSpec, + type ContentPart, + defaultContributorRegistry, + type LaunchSource, + type ResolveCtx, +} from "@superset/launch-context"; +import type { PromptTransport } from "../../../settings/agent-presets"; +import { synthAgentConfig } from "./synth-agent-config"; + +export interface HostAgentPresetRow { + presetId: string; + label: string; + command: string; + args: string[]; + promptTransport: PromptTransport; + promptArgs: string[]; + env: Record; +} + +export interface BuildAgentLaunchInput { + projectId: string; + preset: HostAgentPresetRow; + prompt?: string; + internalTaskIds: string[]; + githubIssueUrls: string[]; + linkedPrUrl?: string; + attachments: AttachmentFile[]; + /** + * How to fetch issue/PR/task bodies. Wiring layer (#26) supplies one + * that calls the host's gh-CLI helpers + (optionally) the cloud API + * for internal tasks. Kept injected so this module stays unit-testable + * without those side effects. + */ + resolveCtx: ResolveCtx; +} + +export interface TerminalLaunchPlan { + kind: "terminal"; + label: string; + spawn: { + command: string; + args: string[]; + env: Record; + }; + /** + * For stdin-transport agents: the prompt text to pipe into the spawned + * process's stdin after spawn. Undefined when transport is "argv" (the + * prompt is already in `spawn.args`) or when there's no prompt at all. + */ + stdinPrompt?: string; + attachmentsToWrite: AttachmentFile[]; +} + +/** + * Build a launch plan for a terminal agent from PR1-shaped preset data + * + a launch context. Returns null when there's no agent, no sources, + * or the resulting spec is empty. + * + * Chat agents are not handled in this PR — task #29 will add a parallel + * `ChatLaunchPlan` branch. For now this module is terminal-only. + */ +export async function buildAgentLaunch( + input: BuildAgentLaunchInput, +): Promise { + const sources = buildLaunchSources(input); + if (sources.length === 0) return null; + + const ctx = await buildLaunchContext( + { + projectId: input.projectId, + sources, + agent: { id: input.preset.presetId as AgentDefinitionId }, + }, + { + contributors: defaultContributorRegistry, + resolveCtx: input.resolveCtx, + }, + ); + + const spec = buildLaunchSpec( + ctx, + synthAgentConfig({ + presetId: input.preset.presetId, + label: input.preset.label, + command: input.preset.command, + promptTransport: input.preset.promptTransport, + }), + ); + if (!spec) return null; + + const { attachmentsToWrite, inlineByIndex } = assignFilenamesAndCollect( + spec.user, + spec.attachments, + ); + const promptText = flattenUserContentForTerminal(spec.user, inlineByIndex); + + return composeTerminalPlan({ + preset: input.preset, + promptText, + attachmentsToWrite, + }); +} + +function buildLaunchSources(input: BuildAgentLaunchInput): LaunchSource[] { + const sources: LaunchSource[] = []; + + const prompt = input.prompt?.trim(); + if (prompt) { + sources.push({ + kind: "user-prompt", + content: [{ type: "text", text: prompt }], + }); + } + + for (const taskId of input.internalTaskIds) { + sources.push({ kind: "internal-task", id: taskId }); + } + + for (const url of input.githubIssueUrls) { + sources.push({ kind: "github-issue", url }); + } + + if (input.linkedPrUrl) { + sources.push({ kind: "github-pr", url: input.linkedPrUrl }); + } + + for (const file of input.attachments) { + sources.push({ kind: "attachment", file }); + } + + return sources; +} + +/** + * Compose the spawn argv for a terminal preset. Mirrors the launch + * resolution comment in `agent-presets.ts`: + * + * prompt + * ? [command, ...args, ...promptArgs, ...(transport === "argv" ? [prompt] : [])] + * : [command, ...args] + * + * Stdin-transport prompts are returned as `stdinPrompt` for the + * spawner to write after spawn. No shell-string escape hatches. + */ +function composeTerminalPlan(input: { + preset: HostAgentPresetRow; + promptText: string; + attachmentsToWrite: AttachmentFile[]; +}): TerminalLaunchPlan { + const { preset, promptText, attachmentsToWrite } = input; + const hasPrompt = promptText.length > 0; + + if (!hasPrompt) { + return { + kind: "terminal", + label: preset.label, + spawn: { + command: preset.command, + args: [...preset.args], + env: { ...preset.env }, + }, + attachmentsToWrite, + }; + } + + const argv: string[] = [...preset.args, ...preset.promptArgs]; + if (preset.promptTransport === "argv") argv.push(promptText); + + return { + kind: "terminal", + label: preset.label, + spawn: { + command: preset.command, + args: argv, + env: { ...preset.env }, + }, + stdinPrompt: preset.promptTransport === "stdin" ? promptText : undefined, + attachmentsToWrite, + }; +} + +function flattenUserContentForTerminal( + user: ContentPart[], + inlineByIndex: Map, +): string { + const out: string[] = []; + user.forEach((part, index) => { + if (part.type === "text") { + out.push(part.text); + return; + } + const filename = inlineByIndex.get(index); + if (!filename) return; + out.push(`![${filename}](.superset/attachments/${filename})`); + }); + return out.join("").trim(); +} + +function assignFilenamesAndCollect( + user: ContentPart[], + attachments: ContentPart[], +): { + attachmentsToWrite: AttachmentFile[]; + inlineByIndex: Map; +} { + const used = new Set(); + const out: AttachmentFile[] = []; + const inlineByIndex = new Map(); + + user.forEach((part, index) => { + if (part.type === "text") return; + const filename = nextUniqueName(part, used, out.length); + inlineByIndex.set(index, filename); + out.push({ filename, mediaType: part.mediaType, data: part.data }); + }); + + for (const part of attachments) { + if (part.type === "text") continue; + const filename = nextUniqueName(part, used, out.length); + out.push({ filename, mediaType: part.mediaType, data: part.data }); + } + + return { attachmentsToWrite: out, inlineByIndex }; +} + +function nextUniqueName( + part: Exclude, + used: Set, + fallbackIndex: number, +): string { + const raw = part.type === "file" ? part.filename : undefined; + const sanitized = raw ? sanitizeFilename(raw) : ""; + let name = sanitized; + if (!name) { + let counter = fallbackIndex + 1; + do { + name = `attachment_${counter}`; + counter++; + } while (used.has(name)); + } else if (used.has(name)) { + const segs = name.split("."); + const ext = segs.length > 1 ? segs.pop() : undefined; + const base = segs.join("."); + let counter = 1; + let candidate: string; + do { + candidate = ext ? `${base}_${counter}.${ext}` : `${name}_${counter}`; + counter++; + } while (used.has(candidate)); + name = candidate; + } + used.add(name); + return name; +} + +function sanitizeFilename(filename: string): string { + const cleaned = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + return cleaned.trim() ? cleaned : ""; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/host-resolve-ctx.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/host-resolve-ctx.ts new file mode 100644 index 00000000000..079bbb1f662 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/host-resolve-ctx.ts @@ -0,0 +1,139 @@ +import type { ResolveCtx } from "@superset/launch-context"; +import type { HostServiceContext } from "../../../../../types"; +import { + fetchGithubIssueContent, + fetchGithubPullRequestContent, +} from "../github-content"; + +/** + * Build a ResolveCtx that fetches issue/PR/task bodies via: + * - host's gh-CLI helpers for GitHub issues/PRs (cached) + * - cloud-API `task.byId` for internal Superset tasks + * + * Each fetch degrades on failure: returns title-only content with + * empty body / null description, logs a warning, never throws past + * the contributor. This matches the renderer's + * `buildResolveCtxFromPending` semantics — a missing body is never + * fatal to the launch. + */ +export function buildHostResolveCtx(input: { + ctx: HostServiceContext; + projectId: string; + signal?: AbortSignal; + githubIssueUrls: string[]; + linkedPrUrl?: string; +}): ResolveCtx { + const { ctx, projectId, githubIssueUrls, linkedPrUrl } = input; + const signal = input.signal ?? new AbortController().signal; + + return { + projectId, + signal, + + fetchIssue: async (url) => { + if (!githubIssueUrls.includes(url)) { + throw Object.assign(new Error(`Issue not found: ${url}`), { + status: 404, + }); + } + const issueNumber = parseIssueNumberFromUrl(url); + if (issueNumber === null) { + return { number: 0, url, title: "", body: "", slug: "" }; + } + try { + const data = await fetchGithubIssueContent( + ctx, + projectId, + issueNumber, + ); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + slug: slugifyTitle(data.title), + }; + } catch (err) { + console.warn( + `[launches] fetchGithubIssueContent failed for #${issueNumber}, using title-only`, + err, + ); + return { number: issueNumber, url, title: "", body: "", slug: "" }; + } + }, + + fetchPullRequest: async (url) => { + if (linkedPrUrl !== url) { + throw Object.assign(new Error(`PR not found: ${url}`), { + status: 404, + }); + } + const prNumber = parsePrNumberFromUrl(url); + if (prNumber === null) { + return { number: 0, url, title: "", body: "", branch: "" }; + } + try { + const data = await fetchGithubPullRequestContent( + ctx, + projectId, + prNumber, + ); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + branch: data.branch, + }; + } catch (err) { + console.warn( + `[launches] fetchGithubPullRequestContent failed for #${prNumber}, using title-only`, + err, + ); + return { number: prNumber, url, title: "", body: "", branch: "" }; + } + }, + + fetchInternalTask: async (id) => { + try { + const task = await ctx.api.task.byId.query(id); + if (task) { + return { + id: task.id, + slug: slugifyTitle(task.title), + title: task.title, + description: task.description ?? null, + }; + } + } catch (err) { + console.warn( + `[launches] task.byId failed for ${id}, using title-only`, + err, + ); + } + return { id, slug: "", title: "", description: null }; + }, + }; +} + +/** `https://github.com/owner/repo/issues/123` → 123 */ +function parseIssueNumberFromUrl(url: string): number | null { + const match = url.match(/\/issues\/(\d+)(?:[/?#]|$)/); + const n = match?.[1] ? Number.parseInt(match[1], 10) : Number.NaN; + return Number.isFinite(n) && n > 0 ? n : null; +} + +/** `https://github.com/owner/repo/pull/45` → 45 */ +function parsePrNumberFromUrl(url: string): number | null { + const match = url.match(/\/pull\/(\d+)(?:[/?#]|$)/); + const n = match?.[1] ? Number.parseInt(match[1], 10) : Number.NaN; + return Number.isFinite(n) && n > 0 ? n : null; +} + +function slugifyTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/index.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/index.ts new file mode 100644 index 00000000000..19496919a47 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/index.ts @@ -0,0 +1,15 @@ +export { + buildAgentLaunch, + type BuildAgentLaunchInput, + type HostAgentPresetRow, + type TerminalLaunchPlan, +} from "./build-agent-launch"; +export { buildHostResolveCtx } from "./host-resolve-ctx"; +export { resolveAttachmentFiles } from "./resolve-attachment-files"; +export { + startTerminalLaunch, + type StartedTerminalLaunch, + type StartTerminalLaunchInput, +} from "./start-terminal-launch"; +export { synthAgentConfig } from "./synth-agent-config"; +export { writeAttachmentsToWorktree } from "./write-attachments"; diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/resolve-attachment-files.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/resolve-attachment-files.test.ts new file mode 100644 index 00000000000..818ec6a694b --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/resolve-attachment-files.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { writeAttachment } from "../../../attachments/storage"; +import { resolveAttachmentFiles } from "./resolve-attachment-files"; + +describe("resolveAttachmentFiles", () => { + let baseDir: string; + let prevEnv: string | undefined; + + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "resolve-attachments-")); + prevEnv = process.env.HOST_MANIFEST_DIR; + process.env.HOST_MANIFEST_DIR = baseDir; + }); + afterEach(() => { + if (prevEnv === undefined) delete process.env.HOST_MANIFEST_DIR; + else process.env.HOST_MANIFEST_DIR = prevEnv; + rmSync(baseDir, { recursive: true, force: true }); + }); + + it("returns an empty array when no ids are passed", () => { + expect(resolveAttachmentFiles([])).toEqual([]); + }); + + it("reads bytes + metadata for each stored attachment", () => { + const id = crypto.randomUUID(); + writeAttachment(new Uint8Array([0x68, 0x69]), { + attachmentId: id, + mediaType: "text/plain", + originalFilename: "hello.txt", + sizeBytes: 2, + createdAt: Date.now(), + }); + + const result = resolveAttachmentFiles([id]); + expect(result).toHaveLength(1); + expect(result[0]?.filename).toBe("hello.txt"); + expect(result[0]?.mediaType).toBe("text/plain"); + expect(Array.from(result[0]?.data ?? [])).toEqual([0x68, 0x69]); + }); + + it("skips ids whose data is missing on disk (degrades gracefully)", () => { + const presentId = crypto.randomUUID(); + writeAttachment(new Uint8Array([0x00]), { + attachmentId: presentId, + mediaType: "application/octet-stream", + sizeBytes: 1, + createdAt: Date.now(), + }); + + const missingId = crypto.randomUUID(); + const result = resolveAttachmentFiles([missingId, presentId]); + expect(result).toHaveLength(1); + expect(result[0]?.filename).toBeUndefined(); + }); +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/resolve-attachment-files.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/resolve-attachment-files.ts new file mode 100644 index 00000000000..4a0cc9ffec1 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/resolve-attachment-files.ts @@ -0,0 +1,30 @@ +import type { AttachmentFile } from "@superset/launch-context"; +import { readAttachment } from "../../../attachments/storage"; + +/** + * Read attachment bytes from the host attachment store for each id. + * Skips ids whose data is missing on disk (deleted between upload + * and create) — non-fatal so a stale attachment id doesn't kill the + * whole launch. + */ +export function resolveAttachmentFiles( + attachmentIds: string[], +): AttachmentFile[] { + const out: AttachmentFile[] = []; + for (const id of attachmentIds) { + try { + const { bytes, metadata } = readAttachment(id); + out.push({ + data: bytes, + mediaType: metadata.mediaType, + filename: metadata.originalFilename, + }); + } catch (err) { + console.warn( + `[launches] resolveAttachmentFiles: skipping attachment ${id} (missing on disk)`, + err, + ); + } + } + return out; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/start-terminal-launch.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/start-terminal-launch.ts new file mode 100644 index 00000000000..34094c4b7c6 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/start-terminal-launch.ts @@ -0,0 +1,54 @@ +import { createTerminalSessionInternal } from "../../../../../terminal/terminal"; +import type { HostServiceContext } from "../../../../../types"; +import type { TerminalLaunchPlan } from "./build-agent-launch"; + +export interface StartTerminalLaunchInput { + ctx: HostServiceContext; + workspaceId: string; + plan: TerminalLaunchPlan; +} + +export interface StartedTerminalLaunch { + terminalId: string; + label: string; +} + +/** + * Spawns the agent in a workspace terminal session and returns the + * resulting terminal id. Composes a shell-string from `plan.spawn` + * (POSIX-quoted argv) and passes it to `createTerminalSessionInternal` + * as `initialCommand`. Stdin-transport prompts get prepended as + * `printf '%s' '' | ` so the prompt reaches the + * spawned process via stdin without leaving it in argv. + * + * Env vars on `plan.spawn.env` are currently ignored — all builtin + * presets seed `env: {}`. Add support if a future preset needs it. + */ +export async function startTerminalLaunch( + input: StartTerminalLaunchInput, +): Promise { + const { ctx, workspaceId, plan } = input; + + const argv = [plan.spawn.command, ...plan.spawn.args].map(shellQuote); + let initialCommand = argv.join(" "); + if (plan.stdinPrompt) { + initialCommand = `printf '%s' ${shellQuote(plan.stdinPrompt)} | ${initialCommand}`; + } + + const terminalId = crypto.randomUUID(); + const result = await createTerminalSessionInternal({ + terminalId, + workspaceId, + db: ctx.db, + eventBus: ctx.eventBus, + initialCommand, + }); + if ("error" in result) return { error: result.error }; + + return { terminalId, label: plan.label }; +} + +/** POSIX single-quote escape: safe for any value passed through a shell. */ +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/synth-agent-config.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/synth-agent-config.test.ts new file mode 100644 index 00000000000..233deb39746 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/synth-agent-config.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "bun:test"; +import { + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, +} from "@superset/shared/agent-prompt-template"; +import { synthAgentConfig } from "./synth-agent-config"; + +describe("synthAgentConfig", () => { + it("synthesizes a TerminalResolvedAgentConfig with shared template defaults", () => { + const config = synthAgentConfig({ + presetId: "claude", + label: "Claude", + command: "claude", + promptTransport: "argv", + }); + + expect(config.id).toBe("claude"); + expect(config.kind).toBe("terminal"); + expect(config.enabled).toBe(true); + expect(config.taskPromptTemplate).toBe(DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE); + expect(config.contextPromptTemplateSystem).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + ); + expect(config.contextPromptTemplateUser).toBe( + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + ); + }); + + it("carries through transport for stdin agents", () => { + const config = synthAgentConfig({ + presetId: "mastracode", + label: "Mastracode", + command: "mastracode", + promptTransport: "stdin", + }); + expect(config.promptTransport).toBe("stdin"); + }); + + it("carries through label and command verbatim", () => { + const config = synthAgentConfig({ + presetId: "codex", + label: "Codex", + command: "codex --sandbox workspace-write", + promptTransport: "argv", + }); + expect(config.label).toBe("Codex"); + expect(config.command).toBe("codex --sandbox workspace-write"); + }); +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/synth-agent-config.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/synth-agent-config.ts new file mode 100644 index 00000000000..effe2718d09 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/synth-agent-config.ts @@ -0,0 +1,46 @@ +import type { AgentDefinitionId } from "@superset/shared/agent-catalog"; +import { + DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, +} from "@superset/shared/agent-prompt-template"; +import type { TerminalResolvedAgentConfig } from "@superset/shared/agent-settings"; +import type { PromptTransport } from "../../../settings/agent-presets"; + +export interface HostPresetRow { + presetId: string; + label: string; + command: string; + promptTransport: PromptTransport; +} + +/** + * Build the minimal `TerminalResolvedAgentConfig` shape that + * `buildLaunchSpec` requires. The host config row only stores spawn + * data (command/args/transport); template fields come from + * `@superset/shared` constants. Per-preset template overrides are + * intentionally not stored on the host row — see PR4 plan decision 5. + * + * `buildLaunchSpec` only reads `contextPromptTemplateSystem` + + * `contextPromptTemplateUser`, but the full type signature is + * required by the function. Other fields are synthesized with safe + * defaults so the object type-checks. + */ +export function synthAgentConfig( + row: HostPresetRow, +): TerminalResolvedAgentConfig { + return { + id: row.presetId as AgentDefinitionId, + kind: "terminal", + source: "builtin", + label: row.label, + enabled: true, + taskPromptTemplate: DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, + contextPromptTemplateSystem: DEFAULT_CONTEXT_PROMPT_TEMPLATE_SYSTEM, + contextPromptTemplateUser: DEFAULT_CONTEXT_PROMPT_TEMPLATE_USER, + command: row.command, + promptCommand: row.command, + promptTransport: row.promptTransport, + overriddenFields: [], + }; +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/write-attachments.test.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/write-attachments.test.ts new file mode 100644 index 00000000000..afc030ab919 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/write-attachments.test.ts @@ -0,0 +1,53 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { writeAttachmentsToWorktree } from "./write-attachments"; + +describe("writeAttachmentsToWorktree", () => { + let worktree: string; + + beforeEach(() => { + worktree = mkdtempSync(join(tmpdir(), "write-attachments-")); + }); + afterEach(() => { + rmSync(worktree, { recursive: true, force: true }); + }); + + it("is a no-op for an empty list", () => { + writeAttachmentsToWorktree(worktree, []); + expect(existsSync(join(worktree, ".superset/attachments"))).toBe(false); + }); + + it("creates the .superset/attachments dir and writes each file", () => { + writeAttachmentsToWorktree(worktree, [ + { + filename: "diff.patch", + mediaType: "text/x-diff", + data: new Uint8Array([0x68, 0x69]), // "hi" + }, + { + filename: "notes.txt", + mediaType: "text/plain", + data: new Uint8Array([0x6f, 0x6b]), // "ok" + }, + ]); + + const dir = join(worktree, ".superset/attachments"); + expect(existsSync(dir)).toBe(true); + expect(readFileSync(join(dir, "diff.patch"), "utf-8")).toBe("hi"); + expect(readFileSync(join(dir, "notes.txt"), "utf-8")).toBe("ok"); + }); + + it("falls back to 'attachment' when filename is undefined", () => { + writeAttachmentsToWorktree(worktree, [ + { + mediaType: "application/octet-stream", + data: new Uint8Array([0x00]), + }, + ]); + expect( + existsSync(join(worktree, ".superset/attachments/attachment")), + ).toBe(true); + }); +}); diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/launches/write-attachments.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/write-attachments.ts new file mode 100644 index 00000000000..4fdd7d29392 --- /dev/null +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/launches/write-attachments.ts @@ -0,0 +1,25 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { AttachmentFile } from "@superset/launch-context"; + +const WORKTREE_ATTACHMENTS_SUBDIR = ".superset/attachments"; + +/** + * Writes pre-resolved AttachmentFile bytes into + * `/.superset/attachments/`. Filenames are + * expected to already be collision-safe (assigned by + * `buildAgentLaunch`'s `assignFilenamesAndCollect`). Creates the + * target dir if missing. No-op for an empty list. + */ +export function writeAttachmentsToWorktree( + worktreePath: string, + attachments: AttachmentFile[], +): void { + if (attachments.length === 0) return; + const dir = join(worktreePath, WORKTREE_ATTACHMENTS_SUBDIR); + mkdirSync(dir, { recursive: true }); + for (const attachment of attachments) { + const filename = attachment.filename ?? "attachment"; + writeFileSync(join(dir, filename), attachment.data); + } +} diff --git a/packages/host-service/src/trpc/router/workspace-creation/shared/types.ts b/packages/host-service/src/trpc/router/workspace-creation/shared/types.ts index 302c2de8ac2..7613b80d0cc 100644 --- a/packages/host-service/src/trpc/router/workspace-creation/shared/types.ts +++ b/packages/host-service/src/trpc/router/workspace-creation/shared/types.ts @@ -8,6 +8,18 @@ export type TerminalDescriptor = { label: string; }; +/** + * One agent or terminal launch surfaced on the create response. The + * pending page consumes this to lay out panes for the new workspace. + * Setup terminals also appear here as `kind: "terminal"` (PR 5 + * collapses the parallel `terminals[]` field). + */ +export type LaunchDescriptor = { + kind: "terminal"; + terminalId: string; + label: string; +}; + export type BranchRow = { name: string; lastCommitDate: number; diff --git a/packages/launch-context/package.json b/packages/launch-context/package.json new file mode 100644 index 00000000000..113a95026b6 --- /dev/null +++ b/packages/launch-context/package.json @@ -0,0 +1,33 @@ +{ + "name": "@superset/launch-context", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./contributors": { + "types": "./src/contributors/index.ts", + "default": "./src/contributors/index.ts" + } + }, + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "typecheck": "tsc --noEmit --emitDeclarationOnly false", + "test": "bun test" + }, + "dependencies": { + "@superset/shared": "workspace:*" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "bun-types": "^1.3.1", + "typescript": "^5.9.3" + } +} diff --git a/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts b/packages/launch-context/src/__fixtures__/attachment.logs-txt.ts similarity index 100% rename from apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts rename to packages/launch-context/src/__fixtures__/attachment.logs-txt.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts b/packages/launch-context/src/__fixtures__/githubIssue.auth-middleware.ts similarity index 100% rename from apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts rename to packages/launch-context/src/__fixtures__/githubIssue.auth-middleware.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts b/packages/launch-context/src/__fixtures__/githubPr.auth-rewrite.ts similarity index 100% rename from apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts rename to packages/launch-context/src/__fixtures__/githubPr.auth-rewrite.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/index.ts b/packages/launch-context/src/__fixtures__/index.ts similarity index 100% rename from apps/desktop/src/shared/context/__fixtures__/index.ts rename to packages/launch-context/src/__fixtures__/index.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts b/packages/launch-context/src/__fixtures__/internalTask.refactor-auth.ts similarity index 100% rename from apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts rename to packages/launch-context/src/__fixtures__/internalTask.refactor-auth.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/packages/launch-context/src/__fixtures__/launchContext.multi-source.ts similarity index 100% rename from apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts rename to packages/launch-context/src/__fixtures__/launchContext.multi-source.ts diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts b/packages/launch-context/src/__fixtures__/launchContext.prompt-only.ts similarity index 100% rename from apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts rename to packages/launch-context/src/__fixtures__/launchContext.prompt-only.ts diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/packages/launch-context/src/buildLaunchSpec.test.ts similarity index 100% rename from apps/desktop/src/shared/context/buildLaunchSpec.test.ts rename to packages/launch-context/src/buildLaunchSpec.test.ts diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.ts b/packages/launch-context/src/buildLaunchSpec.ts similarity index 99% rename from apps/desktop/src/shared/context/buildLaunchSpec.ts rename to packages/launch-context/src/buildLaunchSpec.ts index b7366cbc43c..293f3e2e66b 100644 --- a/apps/desktop/src/shared/context/buildLaunchSpec.ts +++ b/packages/launch-context/src/buildLaunchSpec.ts @@ -116,7 +116,7 @@ function substituteVariables( ): string { return template.replace(PLACEHOLDER_RE, (match, rawKey: string) => { const key = rawKey.trim(); - return Object.hasOwn(variables, key) ? variables[key] : match; + return Object.hasOwn(variables, key) ? (variables[key] ?? match) : match; }); } diff --git a/apps/desktop/src/shared/context/composer.integration.test.ts b/packages/launch-context/src/composer.integration.test.ts similarity index 100% rename from apps/desktop/src/shared/context/composer.integration.test.ts rename to packages/launch-context/src/composer.integration.test.ts diff --git a/apps/desktop/src/shared/context/composer.test.ts b/packages/launch-context/src/composer.test.ts similarity index 100% rename from apps/desktop/src/shared/context/composer.test.ts rename to packages/launch-context/src/composer.test.ts diff --git a/apps/desktop/src/shared/context/composer.ts b/packages/launch-context/src/composer.ts similarity index 100% rename from apps/desktop/src/shared/context/composer.ts rename to packages/launch-context/src/composer.ts diff --git a/apps/desktop/src/shared/context/contributors/attachment.test.ts b/packages/launch-context/src/contributors/attachment.test.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/attachment.test.ts rename to packages/launch-context/src/contributors/attachment.test.ts diff --git a/apps/desktop/src/shared/context/contributors/attachment.ts b/packages/launch-context/src/contributors/attachment.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/attachment.ts rename to packages/launch-context/src/contributors/attachment.ts diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts b/packages/launch-context/src/contributors/githubIssue.test.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/githubIssue.test.ts rename to packages/launch-context/src/contributors/githubIssue.test.ts diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.ts b/packages/launch-context/src/contributors/githubIssue.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/githubIssue.ts rename to packages/launch-context/src/contributors/githubIssue.ts diff --git a/apps/desktop/src/shared/context/contributors/githubPr.test.ts b/packages/launch-context/src/contributors/githubPr.test.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/githubPr.test.ts rename to packages/launch-context/src/contributors/githubPr.test.ts diff --git a/apps/desktop/src/shared/context/contributors/githubPr.ts b/packages/launch-context/src/contributors/githubPr.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/githubPr.ts rename to packages/launch-context/src/contributors/githubPr.ts diff --git a/apps/desktop/src/shared/context/contributors/index.ts b/packages/launch-context/src/contributors/index.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/index.ts rename to packages/launch-context/src/contributors/index.ts diff --git a/apps/desktop/src/shared/context/contributors/internalTask.test.ts b/packages/launch-context/src/contributors/internalTask.test.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/internalTask.test.ts rename to packages/launch-context/src/contributors/internalTask.test.ts diff --git a/apps/desktop/src/shared/context/contributors/internalTask.ts b/packages/launch-context/src/contributors/internalTask.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/internalTask.ts rename to packages/launch-context/src/contributors/internalTask.ts diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.test.ts b/packages/launch-context/src/contributors/userPrompt.test.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/userPrompt.test.ts rename to packages/launch-context/src/contributors/userPrompt.test.ts diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.ts b/packages/launch-context/src/contributors/userPrompt.ts similarity index 100% rename from apps/desktop/src/shared/context/contributors/userPrompt.ts rename to packages/launch-context/src/contributors/userPrompt.ts diff --git a/packages/launch-context/src/index.ts b/packages/launch-context/src/index.ts new file mode 100644 index 00000000000..44cf222ffaf --- /dev/null +++ b/packages/launch-context/src/index.ts @@ -0,0 +1,22 @@ +export { buildLaunchSpec } from "./buildLaunchSpec"; +export { + buildLaunchContext, + CONTRIBUTOR_TIMEOUT_MS, +} from "./composer"; +export { defaultContributorRegistry } from "./contributors"; +export type { + AgentLaunchSpec, + AttachmentFile, + BuildLaunchContextInputs, + ContentPart, + ContextContributor, + ContextSection, + ContributorRegistry, + GitHubIssueContent, + GitHubPullRequestContent, + InternalTaskContent, + LaunchContext, + LaunchSource, + LaunchSourceKind, + ResolveCtx, +} from "./types"; diff --git a/apps/desktop/src/shared/context/types.ts b/packages/launch-context/src/types.ts similarity index 100% rename from apps/desktop/src/shared/context/types.ts rename to packages/launch-context/src/types.ts diff --git a/packages/launch-context/tsconfig.json b/packages/launch-context/tsconfig.json new file mode 100644 index 00000000000..2b801a37647 --- /dev/null +++ b/packages/launch-context/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@superset/typescript/internal-package.json", + "compilerOptions": { + "types": ["bun-types"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/plans/20260502-workspace-create-pr4.md b/plans/20260502-workspace-create-pr4.md new file mode 100644 index 00000000000..b881a20c54e --- /dev/null +++ b/plans/20260502-workspace-create-pr4.md @@ -0,0 +1,370 @@ +# PR 4 Plan: Canonical `workspace.create()` returns launches + +## Summary + +Move the agent-launch decision and session start from the renderer's +post-create dispatch into `workspaceCreation.create` itself. The host +returns `launches: LaunchResult[]` describing already-started sessions, +and the pending page calls `addLaunchPanes(workspaceId, launches)` +(landed in PR 3) before navigating. Eliminates the +`pendingWorkspaces.terminalLaunch` / `chatLaunch` write side and the +mount-effect consumer; the `pendingWorkspaces` columns themselves are +removed in PR 5. + +This PR is the first real consumer of `addLaunchPanes`. After PR 4: +- `workspaceCreation.create` is a **single round-trip**. No follow-up + `dispatchForkLaunch` step. +- The renderer is **no longer responsible for picking the agent** + preset, building the agent command, writing attachments, or minting + terminal IDs. All of that lives in host-service. +- `useConsumePendingLaunch` becomes a permanent no-op (rows never get + `terminalLaunch`/`chatLaunch` set anymore). Removal lives in PR 5. + +## Why this PR exists + +The 7-PR canonical-create plan replaces a chain of renderer-driven +side effects with a single host call. PRs 1–3 built the substrate: + +- **PR 1** moved agent presets onto host-service so any host caller can + resolve them (`settings.getAgentPresets`). +- **PR 2** moved attachments onto host-service so any host caller can + read/write attachment bytes by ID. +- **PR 3** built the pane registry so panes can be written before — or + without — the workspace route mounting. + +PR 4 ties them together. The renderer hands the host a description of +the create intent (project, branch hints, prompt, agent ID, attachment +IDs, linked context); the host runs the worktree creation, picks the +agent preset, writes attachments into the worktree, starts the terminal +or chat session, and returns the workspace plus the started sessions. +The pending page then attaches. + +## Scope + +**In:** +- New host-side `launches: LaunchResult[]` field on the `create` + response (and the `checkout` / pr-checkout response, since the modal + uses the same dispatch path for fork + pr-checkout). +- Host-service: port `buildForkAgentLaunch` logic (renderer → + host-service), pick agent preset by `composer.agentId`, build the + command (terminal) or chat-launch config (chat), start the + terminal/chat session, return descriptors. +- Host-service: write attachments into the worktree as part of create + using PR 2's attachment store (resolve by attachment ID, not base64 + bytes on the wire). +- Renderer: pending page calls `addLaunchPanes(workspaceId, launches)` + immediately after create resolves; stops calling `dispatchForkLaunch` + and stops writing `terminalLaunch` / `chatLaunch` on the row. +- Renderer: modal switches from "send attachment bytes inline" to + "upload attachments to host first, send IDs in `linkedContext`". + +**Out (lives in PR 5):** +- Removing `pendingWorkspaces.terminalLaunch` / `chatLaunch` columns. +- Removing `useConsumePendingLaunch` hook. +- Removing `dispatchForkLaunch` / `buildForkAgentLaunch` files. +- Removing `pending-attachment-store` (renderer-side electron storage + of pending attachments). + +PR 4 leaves all of those in place and dormant so the diff is the bare +minimum needed to swap the dispatch path. PR 5 reaps. + +**Out (lives in PR 6):** +- Migrating CLI / automations onto the same `create` endpoint. + +## Architecture + +### New shared type: `LaunchResult` + +```ts +// packages/shared/src/launches.ts (or co-located) +export type LaunchResult = + | { kind: "terminal"; terminalId: string; label?: string } + | { kind: "chat"; chatSessionId: string; label?: string }; +``` + +Used as the contract between host-service create and the renderer's +`addLaunchPanes`. Identical to the type in +`packages/desktop-renderer/.../addLaunchPanes.ts`. + +### Host create response shape + +```ts +return { + workspace: cloudRow, + launches: LaunchResult[], // NEW — replaces post-create dispatch + terminals: TerminalDescriptor[], // KEPT for setup terminal — see below + warnings: string[], +}; +``` + +Setup terminal: continues to return as `terminals[0]` AND as a +`{ kind: "terminal", terminalId, label: "Setup" }` entry in `launches`. +Separate fields because (a) `terminals` is used for non-pane reporting +elsewhere and (b) the setup terminal should land in a pane like any +other launch. PR 5 collapses this when the legacy consumers are gone. + +### Host-side launch building + +New module: `packages/host-service/src/trpc/router/workspace-creation/shared/launches/`. + +``` +launches/ +├── build-agent-launch.ts # agentId + composer → command/chat config +├── start-terminal-launch.ts # spawn PTY, return terminalId +├── start-chat-launch.ts # create chat session, return sessionId +├── write-attachments.ts # resolve attachment IDs → worktree files +└── index.ts +``` + +`build-agent-launch.ts` ports `buildForkAgentLaunch` from the renderer +(`apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts`). +Host-service version reads agent presets from the host's local DB +(PR 1's `settings.agent_configs` table) instead of the electron tRPC +preset table. + +`write-attachments.ts` uses PR 2's attachment store: takes a list of +attachment IDs from `composer.linkedContext.attachmentIds`, reads each +from the host attachment store, writes to +`/.superset/attachments/`. Replaces the renderer's +`writeAttachmentsToWorktree` helper. + +### Host create flow (post-PR 4) + +``` +ensureMainWorkspace + ↓ +deduplicateBranchName + worktree add + ↓ +register cloud row + local workspaces row + ↓ +fire-and-forget AI rename + ↓ +[NEW] writeAttachmentsToWorktree (if linkedContext.attachmentIds) + ↓ +[NEW] buildAgentLaunch (if composer.agentId) + ↓ +[NEW] startTerminalLaunch / startChatLaunch + ↓ +[KEPT] startSetupTerminalIfPresent (if runSetupScript) — also pushed + into launches[] + ↓ +return { workspace, launches, terminals, warnings } +``` + +### Input schema additions + +```ts +createInputSchema = z.object({ + ...existing, + composer: z.object({ + ...existing, + agentId: z.string().optional(), // NEW — agent preset ID to launch + }), + linkedContext: z.object({ + ...existing, + attachmentIds: z.array(z.string().uuid()).optional(), // NEW + // attachments (base64 inline) stays for now — drop in PR 5 + }).optional(), +}); +``` + +`agentId` matches today's `pending.agentId` (the picker stores it on +the row). The renderer reads it from `pending` and passes it through. + +### Pending page changes + +```ts +// before +const result = await createWorkspace(buildForkPayload(...)); +ensureWorkspaceInSidebar(result.workspace.id, projectId); +await dispatchForkLaunch({ ...stashes on row }); +collections.pendingWorkspaces.update(... { status: "succeeded" }); + +// after +const result = await createWorkspace(buildForkPayload(...)); +ensureWorkspaceInSidebar(result.workspace.id, projectId); +addLaunchPanes(result.workspace.id, result.launches); +collections.pendingWorkspaces.update(... { status: "succeeded" }); +``` + +`buildForkPayload` changes: stops attaching `loadedAttachments` (base64 +bytes), instead uploads them to host via `attachments.upload` (PR 2) +and attaches the returned IDs to `linkedContext.attachmentIds`. + +The pr-checkout branch threads `agentId` and `linkedContext.attachmentIds` +through `buildPrCheckoutPayload` the same way. + +### Pane registry init at create time + +Today the registry initializes inside `CollectionsProvider`'s `useMemo`, +which runs when the auth/org context renders. The pending page is also +rendered inside that provider, so by the time +`addLaunchPanes(workspaceId, ...)` is called, the registry is already +initialized. No new boot ordering required. + +## Files Changed + +**New:** +- `packages/host-service/src/trpc/router/workspace-creation/shared/launches/build-agent-launch.ts` +- `packages/host-service/src/trpc/router/workspace-creation/shared/launches/start-terminal-launch.ts` +- `packages/host-service/src/trpc/router/workspace-creation/shared/launches/start-chat-launch.ts` +- `packages/host-service/src/trpc/router/workspace-creation/shared/launches/write-attachments.ts` +- `packages/host-service/src/trpc/router/workspace-creation/shared/launches/index.ts` +- `packages/shared/src/launches.ts` +- Tests for each new module. + +**Modified:** +- `packages/host-service/src/trpc/router/workspace-creation/procedures/create.ts` — call new launches helpers, return `launches`. +- `packages/host-service/src/trpc/router/workspace-creation/procedures/checkout.ts` — same. +- `packages/host-service/src/trpc/router/workspace-creation/schemas.ts` — add `composer.agentId`, `linkedContext.attachmentIds`. +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx` — drop dispatchForkLaunch call site, add `addLaunchPanes` call site. +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts` — upload attachments via `attachments.upload`, send `attachmentIds` instead of inline base64 (for fork + pr-checkout payloads). +- `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts` — extend `CreateWorkspaceInput` type with `agentId` + `attachmentIds`. + +**Untouched (removed in PR 5):** +- `useConsumePendingLaunch` — left in place; will no-op since rows won't get launch data set. +- `dispatchForkLaunch` / `buildForkAgentLaunch` — left in place but unreachable from the pending page. + +## Tests + +Host-service: +- `build-agent-launch` resolves a preset by ID, produces the right + command for terminal preset, the right `ChatLaunchConfig` for chat + preset. +- `start-terminal-launch` spawns a PTY, returns a non-empty terminalId, + registers the session in `runtime/terminals`. +- `start-chat-launch` creates a chat session, returns sessionId, + carries `initialPrompt` / `initialFiles` through. +- `write-attachments` resolves IDs from PR 2's store, writes files to + `/.superset/attachments/`. +- `create` integration: input with `composer.agentId="codex"` returns + `launches: [{ kind: "terminal", terminalId, label: "Codex" }]` and + the terminal session is reachable via `terminals.attach`. +- `create` integration: input with no `composer.agentId` returns + `launches: []` (or just setup terminal if present). + +Renderer: +- Pending page calls `addLaunchPanes` with the host's returned + `launches` array on success. +- Pending page does **not** write `terminalLaunch` / `chatLaunch` to + the row when intent is `fork` or `pr-checkout`. +- Existing `useConsumePendingLaunch` stays inert when row has no + launch fields (regression check). + +## Risks and Rollout + +- **Behavior change: agent launches now happen server-side.** A bug in + `build-agent-launch` (e.g. wrong env, wrong cwd) surfaces as "agent + spawned but didn't behave as expected" instead of "renderer wrote + bad command into terminal." Mitigated by porting the existing logic + rather than rewriting, and by integration tests that compare + produced commands against the renderer's output for the same + presets. +- **Attachment ID flow.** PR 2 added the upload endpoint but no + caller. PR 4 is the first real consumer. If `attachments.upload` + has a bug (e.g. dropping mediaType), it surfaces here. Mitigated by + PR 2's tests covering round-trip; add an end-to-end test in PR 4 + too. +- **Setup terminal duplication.** Returning the setup terminal in both + `terminals` and `launches` makes both paths work simultaneously, + but means the setup terminal could land twice if a caller dedupes + poorly. The pending page only consumes `launches` — `terminals` is + reported elsewhere. Risk is low but real. +- **Chat session lifecycle.** Chat sessions today are created lazily + on first user message. Pre-creating in `start-chat-launch` means + there's now an empty chat row in the DB at create time, even if the + user never sends a message. Need to confirm chat session reaper + handles this; if not, add to PR 4 scope. +- **No rollback strategy yet for partial-launch failure.** If + `worktree add` succeeds, `start-terminal-launch` fails, what does + the user see? Today the renderer eats this in a try/catch and + toasts. PR 4 should do the same in the host: if a launch fails, + push a `warnings[]` entry, return `launches: []`, leave the + workspace alive. The renderer toasts the warning. + +## Follow-Ups (PR 5) + +- Drop `pendingWorkspaces.terminalLaunch` / `chatLaunch` columns from + `dashboardSidebarLocal/schema.ts`. +- Delete `useConsumePendingLaunch`, `dispatchForkLaunch`, + `buildForkAgentLaunch`, `pending-attachment-store`, + `writeAttachmentsToWorktree`. +- Drop `linkedContext.attachments` (base64) from the create schema. +- Collapse host-side `terminals` / `launches` duplication. + +## Decisions (confirmed via /decide) + +1. **Agent preset selection.** Renderer passes `composer.agentId`; + host resolves the preset from PR 1's `host_agent_configs` table. + Future CLI / automation callers don't need to know how presets + work. **PR 4 extends PR 1's schema with template columns** (see + decision 5) so the host can build the full prompt itself, not + just `[command, ...args, prompt]`. +2. **Launches shape.** Always `launches: LaunchResult[]`. Today emits + 0 or 1 agent launches plus an optional setup launch; future + multi-pane create needs no schema change. +3. **Attachments.** First real consumer of PR 2's `attachments.upload`. + Renderer uploads bytes, sends IDs as `linkedContext.attachmentIds`, + host writes to worktree. Upload happens at the pending-page + call-site (keeps `useCreateDashboardWorkspace` thin). +5. **Templates stay as shared-package constants (no per-preset + columns).** PR 4's host-side launch builder synthesizes a + `ResolvedAgentConfig` inline using + `DEFAULT_CONTEXT_PROMPT_TEMPLATE_*` from `@superset/shared`, then + passes it to `buildLaunchSpec`. No new columns on + `host_agent_configs`. Per-preset template customization will get + its own storage flow when an actual product use case motivates it + — premature columns add migration cost and row-level duplication + (9 rows × 3 templates) for zero current benefit. We **also + deliberately do not port** V1's `promptCommand` / + `promptCommandSuffix` shell-string escape hatches — pure + argv-array spawn (`[command, ...args, ...(prompt ? promptArgs : + [])]`) plus stdin transport covers every builtin. Mastracode + moves to stdin transport (`prompt | mastracode`) instead of V1's + `--prompt` + `; mastracode` REPL re-entry dance. + +6. **Setup terminal becomes a launch.** Today there are *three* + write paths into pane state — `buildSetupPaneLayout` writes + `v2WorkspaceLocalState.paneLayout` directly, `dispatchForkLaunch` + stashes on `pendingWorkspaces.terminalLaunch/chatLaunch`, and + user edits go through the pane registry. PR 4 collapses paths 1 + and 2 into one `addLaunchPanes(workspaceId, launches)` call. The + setup terminal gets a `{kind:"terminal", terminalId, label:"Setup"}` + entry in `launches[]` and `buildSetupPaneLayout` joins PR 5's reap + list. + +Constraints carried over from `apps/desktop/plans/v2-create-decisions-final.md`: +- **No new pending-row phases.** Decision 7: a single `creating` + status. PR 4 must not surface `building-launches` etc. +- **No `outcome` field in the response.** Decision 11. The shape + stays `{ workspace, launches, terminals, warnings }`. + +## Implementation order (vertical slices) + +1. **Audit + plan update.** Read existing host terminal-start + + `buildForkAgentLaunch`. Confirm what's reusable, what needs + porting. ✅ +2. **Extract `packages/launch-context`.** Pure file move; both + renderer and host-service can now import the composer + + buildLaunchSpec. ✅ (commit `7cbd388ac`) +3. **Migrate mastracode preset to stdin transport.** No schema + change — PR 4's launch builder reads template defaults inline + from `@superset/shared` constants, so `host_agent_configs` keeps + its PR1 shape. Per-preset template customization deferred to a + future flow. Mastracode `argv` + `--prompt` → `stdin` + no args. + No `promptCommand` / suffix — argv-array spawn covers all + builtins. ✅ +4. **Terminal launch slice.** Build host-side `launches/` module + (terminal-only): `build-agent-launch.ts` (calls launch-context + with the host preset row), `start-terminal-launch.ts` (wraps + `createTerminalSessionInternal`), `write-attachments.ts` (uses + PR 2's attachment store). Wire into `create.ts`, update pending + page to call `addLaunchPanes`. Manual-test a fork with an agent + selected. +5. **Chat launch slice.** Add `start-chat-launch.ts`. Branch in + `build-agent-launch` by preset transport. Manual-test chat + preset. (Chat agent is the singleton `superset-chat`; no host + table row.) +6. **Setup terminal as launch.** Push setup into `launches[]`, drop + the `buildSetupPaneLayout` direct-write from the pending page. +7. **Open PR.**