Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/app/api/agents/[id]/run/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { db, schema } from '@/lib/db'
import { eq } from 'drizzle-orm'
import { runAgent, writeAgentSkillConfig } from '@/lib/claude'
import { resolveAgentCwd } from '@/lib/directories'

export const runtime = 'nodejs'
// Allow long-running streams; default Vercel limit is short, but we're
Expand Down Expand Up @@ -53,9 +54,14 @@ export async function POST(
)
}

// Pin the agent's skills into the workspace's .claude config (best
// Resolve which working directory this agent should run in. If the
// agent is bound to a directory we use that; otherwise the
// workspace's first/default directory; otherwise legacy workspace.cwd.
const cwd = await resolveAgentCwd(agent, workspace)

// Pin the agent's skills into the chosen cwd's .claude config (best
// effort; failures are non-fatal and surface in stderr instead).
await writeAgentSkillConfig(workspace.cwd, agent.skills ?? [])
await writeAgentSkillConfig(cwd, agent.skills ?? [])

// Insert a `running` row so we can update it when the stream ends.
const [run] = await db
Expand All @@ -80,7 +86,11 @@ export async function POST(
try {
for await (const event of runAgent({
prompt,
cwd: workspace.cwd,
cwd,
// Memory stays at the workspace's default cwd so every
// agent in this workspace gets the same shared context,
// regardless of which directory it runs in.
memoryCwd: workspace.cwd,
systemPrompt: agent.systemPrompt ?? '',
model: agent.model,
skills: agent.skills ?? [],
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/agents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,13 @@ export async function POST(req: Request) {
const skills = Array.isArray(body.skills)
? body.skills.filter((s: unknown): s is string => typeof s === 'string')
: []
const directoryId =
typeof body.directoryId === 'string' && body.directoryId
? body.directoryId
: null
const [row] = await db
.insert(schema.agents)
.values({ workspaceId, name, systemPrompt, model, skills })
.values({ workspaceId, directoryId, name, systemPrompt, model, skills })
.returning()
Comment on lines +47 to 54
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Validate directoryId against the agent’s workspace before insert.

directoryId is persisted without verifying it belongs to workspaceId. That allows cross-workspace bindings and can produce unexpected FK failures.

Suggested fix
 import { NextResponse } from 'next/server'
 import { db, schema } from '`@/lib/db`'
-import { eq, desc } from 'drizzle-orm'
+import { and, eq, desc } from 'drizzle-orm'
@@
-  const directoryId =
-    typeof body.directoryId === 'string' && body.directoryId
-      ? body.directoryId
-      : null
+  const rawDirectoryId =
+    typeof body.directoryId === 'string' ? body.directoryId.trim() : ''
+  let directoryId: string | null = null
+  if (rawDirectoryId) {
+    const [dir] = await db
+      .select({ id: schema.workspaceDirectories.id })
+      .from(schema.workspaceDirectories)
+      .where(
+        and(
+          eq(schema.workspaceDirectories.id, rawDirectoryId),
+          eq(schema.workspaceDirectories.workspaceId, workspaceId),
+        ),
+      )
+    if (!dir) {
+      return NextResponse.json(
+        { error: 'invalid directoryId for workspace' },
+        { status: 400 },
+      )
+    }
+    directoryId = dir.id
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const directoryId =
typeof body.directoryId === 'string' && body.directoryId
? body.directoryId
: null
const [row] = await db
.insert(schema.agents)
.values({ workspaceId, name, systemPrompt, model, skills })
.values({ workspaceId, directoryId, name, systemPrompt, model, skills })
.returning()
const rawDirectoryId =
typeof body.directoryId === 'string' ? body.directoryId.trim() : ''
let directoryId: string | null = null
if (rawDirectoryId) {
const [dir] = await db
.select({ id: schema.workspaceDirectories.id })
.from(schema.workspaceDirectories)
.where(
and(
eq(schema.workspaceDirectories.id, rawDirectoryId),
eq(schema.workspaceDirectories.workspaceId, workspaceId),
),
)
if (!dir) {
return NextResponse.json(
{ error: 'invalid directoryId for workspace' },
{ status: 400 },
)
}
directoryId = dir.id
}
const [row] = await db
.insert(schema.agents)
.values({ workspaceId, directoryId, name, systemPrompt, model, skills })
.returning()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/agents/route.ts` around lines 47 - 54, directoryId is being saved
without ensuring it belongs to the same workspace (workspaceId), enabling
cross-workspace associations; before calling db.insert on schema.agents, if
directoryId is non-null query the directories table (e.g. via
db.select().from(schema.directories).where({ id: directoryId, workspaceId }))
and if no row is returned reject the request (throw/return 400) so only
directories that match workspaceId are allowed, then proceed with the existing
db.insert into schema.agents; reference symbols: directoryId, workspaceId, db,
schema.agents, schema.directories, and the insert call.

return NextResponse.json({ agent: row }, { status: 201 })
}
44 changes: 44 additions & 0 deletions src/app/api/workspaces/[id]/directories/[dirId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { db, schema } from '@/lib/db'
import { and, eq } from 'drizzle-orm'
import { listDirectories } from '@/lib/directories'

export const runtime = 'nodejs'

/**
* DELETE /api/workspaces/[id]/directories/[dirId]
*
* Refuses to delete the last directory of a workspace. The workspace
* always needs at least one cwd to spawn agents in. Agents bound to
* the deleted directory get their directory_id set to NULL (via the
* schema's ON DELETE SET NULL) and will fall back to the first
* remaining directory at run time.
*/
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; dirId: string }> },
) {
const { id, dirId } = await params
const existing = await listDirectories(id)
if (!existing.find((d) => d.id === dirId)) {
return NextResponse.json(
{ error: 'directory not found in this workspace' },
{ status: 404 },
)
}
if (existing.length <= 1) {
return NextResponse.json(
{ error: 'cannot remove the last directory' },
{ status: 400 },
)
}
await db
.delete(schema.workspaceDirectories)
.where(
and(
eq(schema.workspaceDirectories.id, dirId),
eq(schema.workspaceDirectories.workspaceId, id),
),
)
return NextResponse.json({ ok: true })
Comment on lines +22 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make last-directory enforcement atomic.

The guard (existing.length <= 1) and the DELETE run in separate queries. Concurrent delete requests can both pass validation and remove all directories.

Suggested direction
 export async function DELETE(
@@
-  const existing = await listDirectories(id)
-  if (!existing.find((d) => d.id === dirId)) {
-    return NextResponse.json(
-      { error: 'directory not found in this workspace' },
-      { status: 404 },
-    )
-  }
-  if (existing.length <= 1) {
-    return NextResponse.json(
-      { error: 'cannot remove the last directory' },
-      { status: 400 },
-    )
-  }
-  await db
-    .delete(schema.workspaceDirectories)
-    .where(
-      and(
-        eq(schema.workspaceDirectories.id, dirId),
-        eq(schema.workspaceDirectories.workspaceId, id),
-      ),
-    )
+  const result = await db.transaction(async (tx) => {
+    const existing = await tx
+      .select({ id: schema.workspaceDirectories.id })
+      .from(schema.workspaceDirectories)
+      .where(eq(schema.workspaceDirectories.workspaceId, id))
+
+    if (!existing.find((d) => d.id === dirId)) return { status: 404 as const }
+    if (existing.length <= 1) return { status: 400 as const }
+
+    await tx
+      .delete(schema.workspaceDirectories)
+      .where(
+        and(
+          eq(schema.workspaceDirectories.id, dirId),
+          eq(schema.workspaceDirectories.workspaceId, id),
+        ),
+      )
+    return { status: 200 as const }
+  })
+
+  if (result.status === 404) {
+    return NextResponse.json(
+      { error: 'directory not found in this workspace' },
+      { status: 404 },
+    )
+  }
+  if (result.status === 400) {
+    return NextResponse.json(
+      { error: 'cannot remove the last directory' },
+      { status: 400 },
+    )
+  }
   return NextResponse.json({ ok: true })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/workspaces/`[id]/directories/[dirId]/route.ts around lines 22 -
43, The current check (listDirectories) and the delete (db.delete on
schema.workspaceDirectories) are separate and race-prone; make the
last-directory enforcement atomic by moving the validation and delete into a
single database transaction: inside a transaction (using your DB client's
transaction API) select the count of workspaceDirectories for workspaceId with a
row-level lock (or run a COUNT(*) WHERE workspaceId = id FOR UPDATE),
abort/rollback and return 400 if count <= 1, otherwise perform the delete WHERE
id = dirId AND workspaceId = id and commit; update the route handler to use this
transactional flow instead of listDirectories followed by db.delete to prevent
concurrent deletions from removing the last directory.

}
61 changes: 61 additions & 0 deletions src/app/api/workspaces/[id]/directories/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { db, schema } from '@/lib/db'
import { eq } from 'drizzle-orm'
import { listDirectories } from '@/lib/directories'

export const runtime = 'nodejs'

/**
* GET /api/workspaces/[id]/directories
* POST /api/workspaces/[id]/directories { path, label? }
*
* The path is not validated against the filesystem here. The user is
* trusted to point at a real folder, same trust model as the workspace
* cwd. We do require an absolute-ish path: rejecting empty / pure
* whitespace, that's all.
*/

async function loadWorkspace(id: string) {
const [row] = await db
.select()
.from(schema.workspaces)
.where(eq(schema.workspaces.id, id))
return row ?? null
}

export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const workspace = await loadWorkspace(id)
if (!workspace) {
return NextResponse.json({ error: 'workspace not found' }, { status: 404 })
}
const directories = await listDirectories(workspace.id)
return NextResponse.json({ directories })
}

export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const workspace = await loadWorkspace(id)
if (!workspace) {
return NextResponse.json({ error: 'workspace not found' }, { status: 404 })
}
const body = await req.json().catch(() => null)
const path =
body && typeof body.path === 'string' ? body.path.trim() : ''
const label =
body && typeof body.label === 'string' ? body.label.trim() : ''
if (!path) {
return NextResponse.json({ error: 'path is required' }, { status: 400 })
}
Comment on lines +49 to +55
Copy link
Copy Markdown

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

Enforce absolute paths in directory creation.

POST currently accepts any non-empty string. That allows relative paths, which can resolve against process cwd unpredictably and break the workspace directory contract.

Suggested fix
+import path from 'node:path'
 import { NextResponse } from 'next/server'
 import { db, schema } from '`@/lib/db`'
 import { eq } from 'drizzle-orm'
 import { listDirectories } from '`@/lib/directories`'
@@
-  const path =
-    body && typeof body.path === 'string' ? body.path.trim() : ''
+  const directoryPath =
+    body && typeof body.path === 'string' ? body.path.trim() : ''
   const label =
     body && typeof body.label === 'string' ? body.label.trim() : ''
-  if (!path) {
+  if (!directoryPath) {
     return NextResponse.json({ error: 'path is required' }, { status: 400 })
   }
+  if (!path.isAbsolute(directoryPath)) {
+    return NextResponse.json(
+      { error: 'path must be absolute' },
+      { status: 400 },
+    )
+  }
   const [row] = await db
     .insert(schema.workspaceDirectories)
-    .values({ workspaceId: workspace.id, path, label })
+    .values({ workspaceId: workspace.id, path: directoryPath, label })
     .returning()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const path =
body && typeof body.path === 'string' ? body.path.trim() : ''
const label =
body && typeof body.label === 'string' ? body.label.trim() : ''
if (!path) {
return NextResponse.json({ error: 'path is required' }, { status: 400 })
}
import path from 'node:path'
import { NextResponse } from 'next/server'
import { db, schema } from '`@/lib/db`'
import { eq } from 'drizzle-orm'
import { listDirectories } from '`@/lib/directories`'
const directoryPath =
body && typeof body.path === 'string' ? body.path.trim() : ''
const label =
body && typeof body.label === 'string' ? body.label.trim() : ''
if (!directoryPath) {
return NextResponse.json({ error: 'path is required' }, { status: 400 })
}
if (!path.isAbsolute(directoryPath)) {
return NextResponse.json(
{ error: 'path must be absolute' },
{ status: 400 },
)
}
const [row] = await db
.insert(schema.workspaceDirectories)
.values({ workspaceId: workspace.id, path: directoryPath, label })
.returning()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/workspaces/`[id]/directories/route.ts around lines 49 - 55,
Update the validation in the POST handler in route.ts to require an absolute
path: after trimming the incoming body.path (variable path) ensure it is
absolute (use Node's path.isAbsolute or check leading '/' on POSIX) and return a
400 JSON error if not; keep the existing empty check but replace the generic
error with a clear message like "path must be absolute" and ensure subsequent
logic (e.g., any use of path or label) only runs when the absolute-path check
passes.

const [row] = await db
.insert(schema.workspaceDirectories)
.values({ workspaceId: workspace.id, path, label })
.returning()
return NextResponse.json({ directory: row }, { status: 201 })
}
61 changes: 57 additions & 4 deletions src/app/api/workspaces/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { db, schema } from '@/lib/db'
import { desc } from 'drizzle-orm'
import { ensureDefaultDirectory } from '@/lib/directories'

export const runtime = 'nodejs'

Expand All @@ -12,22 +13,74 @@ export async function GET() {
return NextResponse.json({ workspaces: rows })
}

/**
* POST /api/workspaces { name, cwd?, directories?: [{ path, label? }] }
*
* The body accepts either:
* - legacy `cwd` (single path) for back-compat
* - `directories` (array; first entry is the default cwd)
* - both (cwd is used if directories is omitted/empty)
*
* After creating the workspace row, every directory is inserted into
* workspace_directories. The first directory's path is mirrored into
* workspaces.cwd so the legacy column always reflects the default.
*/
export async function POST(req: Request) {
const body = await req.json().catch(() => null)
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'invalid body' }, { status: 400 })
}
const name = typeof body.name === 'string' ? body.name.trim() : ''
const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : ''
if (!name || !cwd) {

// Normalize the directories input. `directories` takes precedence if
// it's a non-empty array; otherwise fall back to `cwd` as a single
// entry.
type DirInput = { path: string; label: string }
let directories: DirInput[] = []
if (Array.isArray(body.directories)) {
const raw: unknown[] = body.directories
for (const d of raw) {
if (!d || typeof d !== 'object') continue
const obj = d as Record<string, unknown>
const path =
typeof obj.path === 'string' ? (obj.path as string).trim() : ''
const label =
typeof obj.label === 'string' ? (obj.label as string).trim() : ''
if (path) directories.push({ path, label })
}
}
if (directories.length === 0) {
const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : ''
if (cwd) directories = [{ path: cwd, label: '' }]
}

if (!name || directories.length === 0) {
return NextResponse.json(
{ error: 'name and cwd are required' },
{ error: 'name and at least one directory are required' },
{ status: 400 },
)
}

const defaultPath = directories[0].path
const [row] = await db
.insert(schema.workspaces)
.values({ name, cwd })
.values({ name, cwd: defaultPath })
.returning()

// Insert every directory in order so the first one is also the
// oldest (resolveAgentCwd treats oldest as default). Labels default
// to "default" for the first row when blank, otherwise empty.
for (let i = 0; i < directories.length; i++) {
const d = directories[i]
await db.insert(schema.workspaceDirectories).values({
workspaceId: row.id,
path: d.path,
label: d.label || (i === 0 ? 'default' : ''),
})
}
// Safety net: ensureDefaultDirectory is a no-op if any row exists
// and a backstop if the loop above somehow skipped the default.
await ensureDefaultDirectory(row.id, defaultPath)

return NextResponse.json({ workspace: row }, { status: 201 })
}
10 changes: 8 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ export default function RootLayout({
lang="en"
className={`${stackSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full bg-neutral-950 text-neutral-100 flex flex-col">
{/* Apply Stack Sans Notch directly on <body> via next/font's
.className (sets font-family on the element, no var
indirection). The header inherits from this, so the navbar
wordmark renders in Stack Sans like the rest of the UI. */}
<body
className={`${stackSans.className} min-h-full bg-neutral-950 text-neutral-100 flex flex-col`}
>
<header className="border-b border-neutral-800 bg-neutral-950/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-3">
<Link
href="/"
className="flex items-center gap-2.5 font-semibold lowercase"
className={`${stackSans.className} flex items-center gap-2.5 font-semibold lowercase`}
>
<ArgusMark className="h-5 w-5 text-amber-500" />
<span className="text-xl leading-none tracking-tight">
Expand Down
6 changes: 3 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ export default async function Home() {
<section>
<h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1>
<p className="mt-1 max-w-2xl text-sm text-neutral-400">
A workspace points at a folder on disk. Agents in that workspace
inherit the folder as their cwd. Skills, settings, and hooks come
from the folder&apos;s <code>.claude/</code> config.
A workspace spans one or more folders on disk. Each agent picks
which folder to run in. Skills, settings, and hooks come from
that folder&apos;s <code>.claude/</code> config.
</p>
</section>

Expand Down
37 changes: 34 additions & 3 deletions src/app/workspace/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { CreateAgentForm } from '@/components/CreateAgentForm'
import { AgentPanel } from '@/components/AgentPanel'
import { SkillsGrid } from '@/components/SkillsGrid'
import { SharedMemoryForm } from '@/components/SharedMemoryForm'
import { WorkspaceDirectories } from '@/components/WorkspaceDirectories'
import { readWorkspaceMemory } from '@/lib/claude'
import { listDirectories } from '@/lib/directories'

export const dynamic = 'force-dynamic'

Expand All @@ -33,6 +35,11 @@ export default async function WorkspacePage({
// string, handled silently by readWorkspaceMemory.
const memoryContent = await readWorkspaceMemory(workspace.cwd)

// Pre-load the directory list so the Directories section, the
// CreateAgentForm dropdown, and the per-agent chip render with no
// client-side fetch waterfall.
const directories = await listDirectories(workspace.id)

return (
<div className="flex flex-col gap-10">
<section>
Expand All @@ -46,7 +53,24 @@ export default async function WorkspacePage({
{workspace.name}
</h1>
<div className="mt-1 font-mono text-xs text-neutral-500">
{workspace.cwd}
{directories.length} {directories.length === 1 ? 'directory' : 'directories'}
</div>
</section>

<section>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-neutral-500">
Directories
</h2>
<p className="mt-1 max-w-2xl text-xs text-neutral-500">
Working directories this workspace spans. Each agent picks one
as its cwd. The first one is the default; new agents that
don&apos;t pick a directory run there.
</p>
<div className="mt-3 max-w-3xl">
<WorkspaceDirectories
workspaceId={workspace.id}
initialDirectories={directories}
/>
</div>
</section>

Expand Down Expand Up @@ -88,7 +112,11 @@ export default async function WorkspacePage({
</div>
)}
{agents.map((agent) => (
<AgentPanel key={agent.id} agent={agent} />
<AgentPanel
key={agent.id}
agent={agent}
directories={directories}
/>
))}
</div>
</section>
Expand All @@ -98,7 +126,10 @@ export default async function WorkspacePage({
Create agent
</h2>
<div className="mt-3 max-w-2xl rounded-lg border border-neutral-800 bg-neutral-900/40 p-4">
<CreateAgentForm workspaceId={workspace.id} />
<CreateAgentForm
workspaceId={workspace.id}
directories={directories}
/>
</div>
</section>
</div>
Expand Down
Loading
Loading