Skip to content

Commit dc095d6

Browse files
committed
feat: chat-first architecture + bud-parity workspace
Flip from project-first to chat-first (bud-exact). A prompt creates a project-less chat (/chats/{id}; v2_threads.project_id nullable + launch_intent); the project is created lazily and atomically on the first run (SELECT ... FOR UPDATE) and named from a prompt slug. Single POST /v1/threads creation path + createChat() client. Removed bootstrapProjectThread / createProjectThread / private createProject and the /projects ?thread= workspace mode; deleted dead thread-header.tsx; "New chat" naming throughout (no more "Untitled"). DB: - 0009: v2_threads.project_id DROP NOT NULL + launch_intent jsonb - startRunWithLazyProject (atomic create+attach+sandbox+run) + shared active-project limit; full nullable-thread audit (projects/runs/search) Web: - app/(app)/chats/[chatId] route; ProjectsShell keyed by threadId with refetch-after-attach (Computer panel appears once the project attaches) - home-composer: authed -> createChat + /chats/{id}; unauthed -> in-place AuthModal preserving the prompt handoff - sidebar / command-palette / nav -> /chats; active-chat via pathname - wired the dead ChatContextRow "New chat in {project}" button Plus accumulated bud-parity workspace work (preview/Computer panel, replay, settings, greeting) verified but previously uncommitted. Verified end-to-end live on local + real Daytona.
1 parent a65b4cc commit dc095d6

66 files changed

Lines changed: 5332 additions & 1667 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,9 @@ packages/skills/src/generated.ts
3131
/*.png
3232
.gemini-clipboard/
3333

34+
# Local agent tooling + QA artifacts (not product code)
35+
/.agents/
36+
/.codex/
37+
3438
# Claude Code transient session state
3539
.claude/last-session.json

apps/agent-worker/src/agent-routing.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface RunEntitlementPolicy {
5555
dailyCostCapUsd?: number;
5656
dailyCostUsdAtRunStart: number;
5757
maxConcurrentSandboxes: number;
58+
maxProjects: number;
5859
quotaPeriodEnd: string;
5960
quotaWarning?: SandboxHoursQuotaWarning;
6061
researchFanoutSubagentLimit: number;
@@ -76,6 +77,12 @@ export async function sandboxForThread(
7677
if (!thread) {
7778
throw new APIError(404, "not_found_thread", "Thread not found", { retriable: false });
7879
}
80+
if (!thread.projectId) {
81+
throw new APIError(404, "not_found_project", "This chat has no workspace yet", {
82+
hint: "Send a message to start the first run, which creates the project sandbox.",
83+
retriable: false,
84+
});
85+
}
7986
return sandboxForProject(env, userId, thread.projectId);
8087
} finally {
8188
await close();
@@ -89,7 +96,7 @@ export async function sandboxForProject(
8996
): Promise<DurableObjectStub<ProjectSandbox>> {
9097
const sandboxName = await projectSandboxName(userId, projectId);
9198
const sandbox = env.PROJECT_SANDBOX.get(env.PROJECT_SANDBOX.idFromName(sandboxName));
92-
await sandbox.registerOwner(userId);
99+
await sandbox.registerOwner(userId, sandboxName);
93100
return sandbox;
94101
}
95102

@@ -109,6 +116,10 @@ export async function requireWritableThreadProject(
109116
if (!thread) {
110117
throw new APIError(404, "not_found_thread", "Thread not found", { retriable: false });
111118
}
119+
if (!thread.projectId) {
120+
// Project-less chat (no first run yet): nothing to gate — the run creates it.
121+
return;
122+
}
112123
const state = await getProjectWriteState(tx, {
113124
projectId: thread.projectId,
114125
userId: parsedUserId,
@@ -212,6 +223,7 @@ export async function runEntitlementPolicy(
212223
dailyCostUsdAtRunStart,
213224
...(limits.dailyCostCapUsd === null ? {} : { dailyCostCapUsd: limits.dailyCostCapUsd }),
214225
maxConcurrentSandboxes: entitlement.maxConcurrentSandboxes,
226+
maxProjects: entitlement.maxProjects,
215227
quotaPeriodEnd: periodEnd.toISOString(),
216228
...(quotaWarning ? { quotaWarning } : {}),
217229
researchFanoutSubagentLimit: Math.min(
@@ -490,7 +502,7 @@ async function sandboxForLegacyThread(
490502
): Promise<DurableObjectStub<ProjectSandbox>> {
491503
const sandboxName = await projectSandboxName(userId, threadId);
492504
const sandbox = env.PROJECT_SANDBOX.get(env.PROJECT_SANDBOX.idFromName(sandboxName));
493-
await sandbox.registerOwner(userId);
505+
await sandbox.registerOwner(userId, sandboxName);
494506
return sandbox;
495507
}
496508

apps/agent-worker/src/durable-objects/mastra-stream-chunks.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ export function mastraChunkToUiChunks(chunk: unknown): UIMessageChunk[] {
5555
return textDeltaChunks(record);
5656
}
5757

58-
if (chunkType === "tool-call" && isSandboxToolChunk(record)) {
59-
return [sandboxStatusChunk("starting")];
58+
if (chunkType === "tool-call") {
59+
return toolCallChunks(record);
6060
}
6161

6262
if (chunkType === "tool-result" && isSandboxToolChunk(record)) {
@@ -219,6 +219,67 @@ function sandboxStatusChunk(status: "ready" | "starting", previewUrl?: string):
219219
};
220220
}
221221

222+
const MAX_TOOL_INPUT_KEYS = 8;
223+
const MAX_TOOL_INPUT_STRING = 256;
224+
225+
// Surface every tool call as a transcript row (bud parity). Sandbox tools also drive
226+
// the Computer-panel status; non-sandbox tools only get the row.
227+
function toolCallChunks(record: Record<string, unknown>): UIMessageChunk[] {
228+
const payload = chunkPayload(record);
229+
const toolName = stringField(payload, "toolName");
230+
if (!toolName) {
231+
return [];
232+
}
233+
const chunks: UIMessageChunk[] = [toolActivityChunk(payload, toolName)];
234+
if (SANDBOX_TOOL_NAMES.has(toolName)) {
235+
chunks.push(sandboxStatusChunk("starting"));
236+
}
237+
return chunks;
238+
}
239+
240+
function toolActivityChunk(payload: Record<string, unknown>, toolName: string): UIMessageChunk {
241+
const toolCallId = stringField(payload, "toolCallId");
242+
const input = toolInputFromPayload(payload);
243+
return {
244+
type: "data-tool",
245+
data: {
246+
v: 1,
247+
toolName,
248+
...(toolCallId ? { toolCallId } : {}),
249+
...(input ? { input } : {}),
250+
},
251+
};
252+
}
253+
254+
// Keep the persisted part small: only scalar args, capped count + string length. The
255+
// transcript row needs the path/command/url/query, not the full (possibly huge) payload.
256+
function toolInputFromPayload(
257+
payload: Record<string, unknown>,
258+
): Record<string, unknown> | undefined {
259+
for (const key of ["args", "input", "toolInput", "arguments"]) {
260+
const raw = asRecord(payload[key]);
261+
if (Object.keys(raw).length > 0) {
262+
return truncateToolInput(raw);
263+
}
264+
}
265+
return undefined;
266+
}
267+
268+
function truncateToolInput(input: Record<string, unknown>): Record<string, unknown> {
269+
const output: Record<string, unknown> = {};
270+
for (const [key, value] of Object.entries(input).slice(0, MAX_TOOL_INPUT_KEYS)) {
271+
if (typeof value === "string") {
272+
output[key] =
273+
value.length > MAX_TOOL_INPUT_STRING ? `${value.slice(0, MAX_TOOL_INPUT_STRING)}…` : value;
274+
} else if (typeof value === "number" || typeof value === "boolean") {
275+
output[key] = value;
276+
} else if (Array.isArray(value)) {
277+
output[key] = `[${value.length} item(s)]`;
278+
}
279+
}
280+
return output;
281+
}
282+
222283
function asRecord(value: unknown): Record<string, unknown> {
223284
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
224285
}

apps/agent-worker/src/durable-objects/project-sandbox.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ const ENSURE_STARTED_DELAY_MS = 2_000;
9999
const SANDBOX_OWNER_USER_ID_KEY = "sandbox_owner_user_id";
100100
const DAYTONA_ID_KEY = "daytona_sandbox_id";
101101
const RUN_LEASES_KEY = "run_leases";
102+
const SANDBOX_NAME_KEY = "sandbox_name";
102103
const PROC_PREFIX = "proc:";
103104

104105
const OwnerUserIdSchema = z.string().uuid();
@@ -117,10 +118,37 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
117118
private daytonaClient: DaytonaClient | undefined;
118119
private daytonaId: string | undefined;
119120
private startedVerifiedAtMs = 0;
121+
private cachedSandboxName: string | undefined;
122+
123+
constructor(ctx: DurableObjectState, env: ProjectSandboxEnv) {
124+
super(ctx, env);
125+
// `ctx.id.name` is only populated when the DO is addressed via idFromName, and the
126+
// runtime drops it when it reconstructs the object (alarm wake / eviction / local
127+
// restart). Persist the name once we ever see it so sandboxName() can recover it on
128+
// those reconstructions instead of throwing and 500-ing every files/metering call.
129+
void ctx.blockConcurrencyWhile(async () => {
130+
const stored = await ctx.storage.get<string>(SANDBOX_NAME_KEY);
131+
const fromId = ctx.id.name;
132+
if (fromId) {
133+
this.cachedSandboxName = fromId;
134+
if (stored !== fromId) {
135+
await ctx.storage.put(SANDBOX_NAME_KEY, fromId);
136+
}
137+
} else if (typeof stored === "string") {
138+
this.cachedSandboxName = stored;
139+
}
140+
});
141+
}
120142

121143
// ----- ownership + quota -----
122144

123-
public async registerOwner(userId: string): Promise<void> {
145+
public async registerOwner(userId: string, sandboxName?: string): Promise<void> {
146+
// The caller addressed us via idFromName(sandboxName); persist it so a later
147+
// alarm-/eviction-reconstructed instance (which loses ctx.id.name) recovers the name.
148+
if (sandboxName && this.cachedSandboxName !== sandboxName) {
149+
this.cachedSandboxName = sandboxName;
150+
await this.ctx.storage.put(SANDBOX_NAME_KEY, sandboxName);
151+
}
124152
const parsedUserId = OwnerUserIdSchema.parse(userId);
125153
const existingUserId = await this.ownerUserId();
126154
if (existingUserId && existingUserId !== parsedUserId) {
@@ -753,7 +781,7 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
753781
}
754782

755783
private sandboxName(): string {
756-
const name = this.ctx.id.name;
784+
const name = this.cachedSandboxName ?? this.ctx.id.name;
757785
if (!name) {
758786
throw new Error("ProjectSandbox must be addressed with idFromName().");
759787
}

apps/agent-worker/src/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,14 @@ agentApp.post("/v1/threads/:threadId/runs", async (c) => {
357357
if (!thread) {
358358
throw new APIError(404, "not_found_thread", "Thread not found", { retriable: false });
359359
}
360-
const sandboxName = await projectSandboxName(userId, thread.projectId);
361360
const policy = await runEntitlementPolicy(c.env, userId);
362361
const result = await withUserContext(db, UserId(userId), (tx) =>
363362
createAgentRunForThread(tx, {
364363
agentName: body.agentName ?? DEFAULT_AGENT_NAME,
364+
maxActiveProjects: policy.maxProjects,
365365
maxConcurrentSandboxes: policy.maxConcurrentSandboxes,
366366
personalization,
367-
sandboxId: sandboxName,
367+
resolveSandboxName: (projectId) => projectSandboxName(userId, projectId),
368368
source: "web",
369369
threadId: parsedThreadId,
370370
userId: parsedUserId,
@@ -399,6 +399,13 @@ agentApp.post("/v1/threads/:threadId/runs", async (c) => {
399399
retriable: false,
400400
});
401401
}
402+
if (result.type === "project-limit-reached") {
403+
throw new APIError(403, "permission_plan_required", "Active project limit reached", {
404+
details: { limit: result.limit, used: result.used },
405+
hint: "Upgrade your plan or archive an existing project before starting another one.",
406+
retriable: false,
407+
});
408+
}
402409
await withUserContext(db, UserId(userId), (tx) =>
403410
createThreadMessage(tx, {
404411
agentRunId: result.run.runId,
@@ -408,6 +415,7 @@ agentApp.post("/v1/threads/:threadId/runs", async (c) => {
408415
userId: UserId(userId),
409416
}),
410417
);
418+
const sandboxName = await projectSandboxName(userId, result.run.projectId);
411419
const warmedSandbox = await sandboxForProject(c.env, userId, result.run.projectId);
412420
c.executionCtx.waitUntil(syncSandboxQuotaPeriod(warmedSandbox, policy.quotaPeriodEnd));
413421
const response = await startAgentRun(

apps/gateway-worker/src/greeting-routes.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { createDb, sumWorkedMinutesToday, withUserContext } from "@cheatcode/db";
12
import { createLogger } from "@cheatcode/observability";
2-
import { type GreetingResponse, GreetingResponseSchema } from "@cheatcode/types";
3+
import { type GreetingResponse, GreetingResponseSchema, type UserId } from "@cheatcode/types";
34
import { z } from "zod";
45

6+
export interface GreetingRouteEnv {
7+
HYPERDRIVE: Hyperdrive;
8+
}
9+
510
const OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
611
const WEATHER_FETCH_TIMEOUT_MS = 1_500;
712
const WEATHER_CACHE_MAX_AGE_SECONDS = 900;
@@ -42,17 +47,52 @@ interface ResolvedGeo {
4247
* failure degrades to `weather: null` while still answering HTTP 200; the client
4348
* falls back to a time-only greeting. Never returns a server clock.
4449
*/
45-
export async function greetingRoute(ctx: ExecutionContext, request: Request): Promise<Response> {
50+
export async function greetingRoute(
51+
env: GreetingRouteEnv,
52+
ctx: ExecutionContext,
53+
request: Request,
54+
userId: UserId,
55+
): Promise<Response> {
4656
const geo = resolveGeo(request);
47-
const weather = await resolveWeather(ctx, geo);
57+
const [weather, workedMinutesToday] = await Promise.all([
58+
resolveWeather(ctx, geo),
59+
resolveWorkedMinutesToday(env, ctx, userId, geo.timezone),
60+
]);
4861
const response: GreetingResponse = {
4962
city: geo.city,
5063
timezone: geo.timezone,
5164
weather,
65+
workedMinutesToday,
5266
};
5367
return Response.json(GreetingResponseSchema.parse(response));
5468
}
5569

70+
/**
71+
* Best-effort daily run-minutes total for the home headline. Any DB failure
72+
* degrades to 0 (the headline falls back to "ready to build") rather than
73+
* failing the greeting — mirrors the weather path's resilience.
74+
*/
75+
async function resolveWorkedMinutesToday(
76+
env: GreetingRouteEnv,
77+
ctx: ExecutionContext,
78+
userId: UserId,
79+
timezone: string | null,
80+
): Promise<number> {
81+
const { db, close } = createDb(env.HYPERDRIVE);
82+
try {
83+
return await withUserContext(db, userId, (tx) =>
84+
sumWorkedMinutesToday(tx, userId, timezone ?? "UTC"),
85+
);
86+
} catch (error) {
87+
createLogger().warn("greeting_worked_minutes_failed", {
88+
reason: error instanceof Error ? error.message : "unknown",
89+
});
90+
return 0;
91+
} finally {
92+
ctx.waitUntil(close());
93+
}
94+
}
95+
5696
function resolveGeo(request: Request): ResolvedGeo {
5797
const cf: unknown = request.cf;
5898
const parsed = CfGeoSchema.safeParse(cf);

apps/gateway-worker/src/index.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,10 @@ import { listAgentsRoute, listToolsRoute } from "./metadata-routes";
7777
import { OPENAPI_DOCUMENT, openApiDocsHtml } from "./openapi";
7878
import { getMyProfileRoute, updateMyProfileRoute } from "./profile-routes";
7979
import {
80+
createChatRoute,
8081
createProjectRoute,
81-
createThreadRoute,
8282
deleteProjectRoute,
83+
deleteThreadRoute,
8384
getProjectRoute,
8485
getThreadRoute,
8586
listProjectsRoute,
@@ -88,6 +89,7 @@ import {
8889
parseProjectParam,
8990
parseThreadParam,
9091
updateProjectRoute,
92+
updateThreadRoute,
9193
} from "./project-routes";
9294
import { ensureFallbackRateLimitHeaders, rateLimit, withRateLimitHeaders } from "./rate-limit";
9395
import { featuredReplaysRoute, replayByIdRoute } from "./replay-routes";
@@ -388,7 +390,7 @@ export const gatewayRoutes = gatewayApp
388390
.get("/v1/greeting", async (c) => {
389391
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
390392
await rateLimit(c, userId, "GET /v1/greeting");
391-
return greetingRoute(c.executionCtx, c.req.raw);
393+
return greetingRoute(c.env, c.executionCtx, c.req.raw, userId);
392394
})
393395

394396
.get("/v1/projects", async (c) => {
@@ -449,22 +451,39 @@ export const gatewayRoutes = gatewayApp
449451
);
450452
})
451453

452-
.post("/v1/projects/:projectId/threads", async (c) => {
454+
.post("/v1/threads", async (c) => {
453455
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
454-
await rateLimit(c, userId, "POST /v1/projects/:projectId/threads");
455-
return createThreadRoute(
456+
await rateLimit(c, userId, "POST /v1/threads");
457+
return createChatRoute(c.env, c.executionCtx, c.req.raw, userId);
458+
})
459+
460+
.get("/v1/threads/:threadId", async (c) => {
461+
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
462+
await rateLimit(c, userId, "GET /v1/threads/:threadId");
463+
return getThreadRoute(c.env, c.executionCtx, parseThreadParam(c.req.param("threadId")), userId);
464+
})
465+
466+
.patch("/v1/threads/:threadId", async (c) => {
467+
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
468+
await rateLimit(c, userId, "PATCH /v1/threads/:threadId");
469+
return updateThreadRoute(
456470
c.env,
457471
c.executionCtx,
458472
c.req.raw,
459-
parseProjectParam(c.req.param("projectId")),
473+
parseThreadParam(c.req.param("threadId")),
460474
userId,
461475
);
462476
})
463477

464-
.get("/v1/threads/:threadId", async (c) => {
478+
.delete("/v1/threads/:threadId", async (c) => {
465479
const userId = await authenticate(c.req.raw, c.env, c.executionCtx);
466-
await rateLimit(c, userId, "GET /v1/threads/:threadId");
467-
return getThreadRoute(c.env, c.executionCtx, parseThreadParam(c.req.param("threadId")), userId);
480+
await rateLimit(c, userId, "DELETE /v1/threads/:threadId");
481+
return deleteThreadRoute(
482+
c.env,
483+
c.executionCtx,
484+
parseThreadParam(c.req.param("threadId")),
485+
userId,
486+
);
468487
})
469488

470489
.get("/v1/threads/:threadId/messages", async (c) => {

apps/gateway-worker/src/openapi-discovery-routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ export const discoverySchemas: Record<string, JsonValue> = {
7979
additionalProperties: false,
8080
properties: {
8181
id: stringSchema({ format: "uuid" }),
82-
projectId: stringSchema({ format: "uuid" }),
83-
projectName: stringSchema(),
82+
projectId: nullableStringSchema({ format: "uuid" }),
83+
projectName: nullableStringSchema(),
8484
title: stringSchema(),
8585
type: { const: "thread", type: "string" },
8686
updatedAt: stringSchema({ format: "date-time" }),

0 commit comments

Comments
 (0)