-
Notifications
You must be signed in to change notification settings - Fork 0
feat: multi-directory workspaces #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make last-directory enforcement atomic. The guard ( 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 |
||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enforce absolute paths in directory creation.
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [row] = await db | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .insert(schema.workspaceDirectories) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .values({ workspaceId: workspace.id, path, label }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .returning() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ directory: row }, { status: 201 }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate
directoryIdagainst the agent’s workspace before insert.directoryIdis persisted without verifying it belongs toworkspaceId. 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
🤖 Prompt for AI Agents