diff --git a/apps/next/src/app/(app)/error.tsx b/apps/next/src/app/(app)/error.tsx new file mode 100644 index 0000000..1690291 --- /dev/null +++ b/apps/next/src/app/(app)/error.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; +import * as Sentry from '@sentry/nextjs'; + +import { Button } from '@spoon/ui'; + +const AppError = ({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) => { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( +
+

Something went wrong

+

+ This page hit an unexpected error. It has been reported. +

+ +
+ ); +}; + +export default AppError; diff --git a/apps/next/src/app/(app)/not-found.tsx b/apps/next/src/app/(app)/not-found.tsx new file mode 100644 index 0000000..0e9a171 --- /dev/null +++ b/apps/next/src/app/(app)/not-found.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link'; + +import { Button } from '@spoon/ui'; + +const NotFound = () => ( +
+

Not found

+

+ This item does not exist, or you do not have access to it. +

+ +
+); + +export default NotFound; diff --git a/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx b/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx index 93fdbfe..29aaef5 100644 --- a/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx +++ b/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { FunctionReturnType } from 'convex/server'; import { useEffect } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; @@ -15,13 +16,33 @@ const AgentWorkspacePage = () => { const router = useRouter(); const params = useParams<{ spoonId: string; jobId: string }>(); const jobId = params.jobId as Id<'agentJobs'>; - const job = useQuery(api.agentJobs.get, { jobId }); + const job = useQuery(api.agentJobs.get, { jobId }) as + | FunctionReturnType + | null + | undefined; useEffect(() => { if (job?.threadId) router.replace(`/threads/${job.threadId}`); }, [job?.threadId, router]); - if (job?.threadId) { + if (job === undefined) { + return ( +
Loading workspace...
+ ); + } + + if (job === null) { + return ( +
+

This workspace was not found.

+ +
+ ); + } + + if (job.threadId) { return (
Opening thread workspace... diff --git a/apps/next/src/app/(app)/threads/[threadId]/page.tsx b/apps/next/src/app/(app)/threads/[threadId]/page.tsx index add0be8..b265e0a 100644 --- a/apps/next/src/app/(app)/threads/[threadId]/page.tsx +++ b/apps/next/src/app/(app)/threads/[threadId]/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { FunctionReturnType } from 'convex/server'; import { useState } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; @@ -33,7 +34,10 @@ const ThreadDetailPage = () => { const router = useRouter(); const params = useParams<{ threadId: string }>(); const threadId = params.threadId as Id<'threads'>; - const details = useQuery(api.threads.get, { threadId }); + const details = useQuery(api.threads.get, { threadId }) as + | FunctionReturnType + | null + | undefined; const createJob = useMutation(api.agentJobs.createForThread); const markResolved = useMutation(api.threads.markResolved); const cancel = useMutation(api.threads.cancel); @@ -43,6 +47,14 @@ const ThreadDetailPage = () => { return
Loading thread...
; } + if (details === null) { + return ( +
+ This thread was not found. +
+ ); + } + const { thread, spoon, latestJob } = details; if (latestJob && spoon) { return ( diff --git a/packages/backend/convex/agentJobs.ts b/packages/backend/convex/agentJobs.ts index 53d0bb8..eca649b 100644 --- a/packages/backend/convex/agentJobs.ts +++ b/packages/backend/convex/agentJobs.ts @@ -710,7 +710,7 @@ export const get = query({ handler: async (ctx, { jobId }) => { const ownerId = await getRequiredUserId(ctx); const job = await ctx.db.get(jobId); - if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.'); + if (job?.ownerId !== ownerId) return null; return job; }, }); diff --git a/packages/backend/convex/threads.ts b/packages/backend/convex/threads.ts index cd3fc29..5525fa2 100644 --- a/packages/backend/convex/threads.ts +++ b/packages/backend/convex/threads.ts @@ -211,7 +211,7 @@ export const get = query({ handler: async (ctx, { threadId }) => { const ownerId = await getRequiredUserId(ctx); const thread = await ctx.db.get(threadId); - if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.'); + if (thread?.ownerId !== ownerId) return null; const spoon = thread.spoonId ? await ctx.db.get(thread.spoonId) : null; const job = thread.latestAgentJobId ? await ctx.db.get(thread.latestAgentJobId) diff --git a/packages/backend/tests/unit/detail-null.test.ts b/packages/backend/tests/unit/detail-null.test.ts new file mode 100644 index 0000000..bfb885f --- /dev/null +++ b/packages/backend/tests/unit/detail-null.test.ts @@ -0,0 +1,118 @@ +import { convexTest } from 'convex-test'; +import { describe, expect, test } from 'vitest'; + +import type { Id } from '../../convex/_generated/dataModel.js'; +import { api } from '../../convex/_generated/api.js'; +import schema from '../../convex/schema'; + +const modules = import.meta.glob('../../convex/**/*.*s'); + +const createUser = async (t: ReturnType, email: string) => + await t.mutation(async (ctx) => { + return await ctx.db.insert('users', { email, name: email }); + }); + +const authed = (t: ReturnType, userId: string) => + t.withIdentity({ + subject: `${userId}|session`, + issuer: 'https://convex.test', + }); + +const seedDetails = async ( + t: ReturnType, + ownerId: Id<'users'>, +) => + await t.mutation(async (ctx) => { + const now = Date.now(); + const spoonId = await ctx.db.insert('spoons', { + ownerId, + name: 'Detail Spoon', + provider: 'gitea', + upstreamOwner: 'upstream', + upstreamRepo: 'detail', + upstreamDefaultBranch: 'main', + upstreamUrl: 'https://git.example.com/upstream/detail', + forkOwner: 'team', + forkRepo: 'detail-spoon', + forkUrl: 'https://git.example.com/team/detail-spoon', + visibility: 'private', + maintenanceMode: 'watch', + syncCadence: 'daily', + productionRefStrategy: 'default_branch', + status: 'active', + createdAt: now, + updatedAt: now, + }); + const threadId = await ctx.db.insert('threads', { + ownerId, + spoonId, + title: 'Private thread', + source: 'user_request', + status: 'open', + priority: 'normal', + createdAt: now, + updatedAt: now, + }); + const requestId = await ctx.db.insert('agentRequests', { + spoonId, + ownerId, + prompt: 'Private work', + status: 'running', + createdAt: now, + updatedAt: now, + }); + const jobId = await ctx.db.insert('agentJobs', { + spoonId, + ownerId, + agentRequestId: requestId, + threadId, + status: 'running', + prompt: 'Private work', + runtime: 'opencode', + baseBranch: 'main', + workBranch: 'spoon/private', + forkOwner: 'team', + forkRepo: 'detail-spoon', + forkUrl: 'https://git.example.com/team/detail-spoon', + upstreamOwner: 'upstream', + upstreamRepo: 'detail', + selectedSecretIds: [], + model: 'openai/gpt-5.1-codex', + reasoningEffort: 'medium', + createdAt: now, + updatedAt: now, + }); + await ctx.db.patch(threadId, { latestAgentJobId: jobId }); + return { threadId, jobId }; + }); + +describe('detail queries for unavailable records', () => { + test('return null for records owned by another user', async () => { + const t = convexTest(schema, modules); + const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; + const otherId = await createUser(t, 'other@example.com'); + const { threadId, jobId } = await seedDetails(t, ownerId); + const other = authed(t, otherId); + + await expect( + other.query(api.threads.get, { threadId }), + ).resolves.toBeNull(); + await expect(other.query(api.agentJobs.get, { jobId })).resolves.toBeNull(); + }); + + test('return null for records that do not exist', async () => { + const t = convexTest(schema, modules); + const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>; + const owner = authed(t, ownerId); + const { threadId, jobId } = await seedDetails(t, ownerId); + await t.mutation(async (ctx) => { + await ctx.db.delete(jobId); + await ctx.db.delete(threadId); + }); + + await expect( + owner.query(api.threads.get, { threadId }), + ).resolves.toBeNull(); + await expect(owner.query(api.agentJobs.get, { jobId })).resolves.toBeNull(); + }); +});