Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/host-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
24 changes: 23 additions & 1 deletion packages/host-service/src/trpc/router/attachments/storage.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
});
});
37 changes: 37 additions & 0 deletions packages/host-service/src/trpc/router/settings/agent-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +145 to +154
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Check the preset before seeding defaults.

A typo currently seeds the bundled defaults even though the helper ultimately returns null. Validate presetId first so bad input doesn't mutate host config state.

♻️ Proposed fix
 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 seeded = seedDefaultsIfEmpty(db);
+	const existing = seeded.find((row) => row.presetId === presetId);
+	if (existing) return toOutput(existing);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/src/trpc/router/settings/agent-configs.ts` around lines
145 - 154, The function findOrCreateHostPresetByPresetId currently calls
seedDefaultsIfEmpty before validating the presetId which can mutate state for
invalid input; change the flow to call getPresetById(presetId) first and if it
returns null immediately return null, then call seedDefaultsIfEmpty(db) and
search for the preset, using toOutput(existing) if found or creating as
needed—update references in this function (findOrCreateHostPresetByPresetId,
getPresetById, seedDefaultsIfEmpty, toOutput) so the host config is not seeded
when presetId is invalid.


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;
Comment on lines +162 to +167
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't hide a persistence failure behind null.

If the insert succeeds but the follow-up read fails, the caller can't distinguish that from "unknown preset" and workspace creation will silently skip launch setup. Throw here instead, like add() does.

🔧 Proposed fix
 	const created = db
 		.select()
 		.from(hostAgentConfigs)
 		.where(eq(hostAgentConfigs.id, insert.id))
 		.get();
-	return created ? toOutput(created) : null;
+	if (!created) {
+		throw new TRPCError({
+			code: "INTERNAL_SERVER_ERROR",
+			message: "Failed to read back inserted host agent config",
+		});
+	}
+	return toOutput(created);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/host-service/src/trpc/router/settings/agent-configs.ts` around lines
162 - 167, The current code masks a persistence/read failure by returning null
after inserting; change it to throw an explicit error when the follow-up select
against hostAgentConfigs (the variable created) returns falsy so callers can
distinguish a read failure from "unknown preset" — mirror the behavior used in
add(): after the
db.select().from(hostAgentConfigs).where(eq(hostAgentConfigs.id,
insert.id)).get() check, if created is falsy throw a descriptive Error (include
the insert id/context) instead of returning null, otherwise return
toOutput(created).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Do not return null on insert readback failure; throw an error so null remains reserved for unknown preset IDs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/trpc/router/settings/agent-configs.ts, line 167:

<comment>Do not return `null` on insert readback failure; throw an error so `null` remains reserved for unknown preset IDs.</comment>

<file context>
@@ -132,6 +132,43 @@ function seedDefaultsIfEmpty(db: HostDb): HostAgentConfigRow[] {
+		.from(hostAgentConfigs)
+		.where(eq(hostAgentConfigs.id, insert.id))
+		.get();
+	return created ? toOutput(created) : null;
+}
+
</file context>
Suggested change
return created ? toOutput(created) : null;
if (!created) {
throw new Error(
`Failed to read back inserted host agent config for presetId: ${presetId}`,
);
}
return toOutput(created);

}

export type { HostAgentConfigOutput };

const updatePatchSchema = z
.object({
label: z.string().trim().min(1).optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -333,6 +342,7 @@ export const create = protectedProcedure
}

const terminals: TerminalDescriptor[] = [];
const launches: LaunchDescriptor[] = [];
const warnings: string[] = [];

if (input.composer.runSetupScript) {
Expand All @@ -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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm what is this? should this be moved to launches?

prompt?: string;
githubIssueUrls: string[];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should move all this to the UI imo, and build the prompt in the UI. it probably simplifies a lot of the api code / makes the endpoint way more accessible for consumers

internalTaskIds: string[];
linkedPrUrl?: string;
attachmentIds: string[];
launches: LaunchDescriptor[];
warnings: string[];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are warnings part of the input out of curiosity? seems like a smell

}

/**
* 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<void> {
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,
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no return value from here?

}
Loading
Loading