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
Original file line number Diff line number Diff line change
Expand Up @@ -213,23 +213,3 @@ describe('POST /api/agent-workspaces/[workspaceId]/conversations/[conversationId
expect(mockCheckSessionAccess).not.toHaveBeenCalled();
});
});

describe('the workspace is waiting for the backfill', () => {
/**
* 503, not 404 and not 200. The conversation exists and is the caller's; the
* SERVER has not migrated this workspace yet, and it becomes ready when an
* operator runs the backfill — which is what 503 says and what nothing else on
* this route does.
*
* The status is asserted rather than assumed because this route has no
* exhaustiveness check: an adversarial review found that deleting the 503
* block lets the request fall through to `200 OK`, reporting success for a
* write the server refused.
*/
it('answers 503 and names the reason', async () => {
mockClaimConversationInSession.mockResolvedValue('awaiting_backfill');
const response = await post();
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ code: 'awaiting_backfill' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -100,17 +100,6 @@ export async function POST(request: Request, context: RouteContext) {
);
}

if (outcome === 'awaiting_backfill') {
// 503, matching `POST /nodes`. The conversation exists and is the caller's;
// the SERVER has not migrated this workspace yet and no action by the
// caller changes that. A 404 here would be a lie about a thread the user
// can see, and would send them looking for it.
return NextResponse.json(
{ error: 'This session is not ready yet. Its data is still being migrated.', code: 'awaiting_backfill' },
{ status: 503 },
);
}

if (outcome === 'session_full') {
return sessionConversationLimitExceeded(request, auth.userId, workspaceId, ROUTE);
}
Expand All @@ -125,9 +114,22 @@ export async function POST(request: Request, context: RouteContext) {
});
}

// `alreadyInSession` (outcome === 'already_in_session'): this call did NOT
// transition anything — same reasoning as the reopen route's
// `alreadyOpen`, so a caller superseded mid-flight knows whether it's
// safe to roll the listing back out.
return NextResponse.json({ ok: true, alreadyInSession: outcome === 'already_in_session' });
// EXHAUSTIVENESS BACKSTOP. Everything below this line is a SUCCESS response,
// so a refusal that reaches it is answered `200 {ok: true}` — the server
// reporting success for a write it declined. This route dispatches with an
// `if`-chain, which TypeScript cannot check for completeness on its own, so
// the narrowing is written down instead: adding a refusal to
// `ClaimConversationOutcome` without an arm above is a COMPILE error here.
//
// This used to be pinned by a test asserting the 503 arm's status ("deleting
// the 503 block lets the request fall through to 200 OK"). That arm and its
// test went with the `awaiting_backfill` refusal at migration 0256, taking the
// guarantee with them for every code that remains — hence stating it in the
// types, where it cannot be deleted by removing a test.
const settled: 'claimed' | 'already_in_session' = outcome;

// `alreadyInSession`: this call did NOT transition anything — same reasoning
// as the reopen route's `alreadyOpen`, so a caller superseded mid-flight
// knows whether it's safe to roll the listing back out.
return NextResponse.json({ ok: true, alreadyInSession: settled === 'already_in_session' });
}
Original file line number Diff line number Diff line change
Expand Up @@ -146,23 +146,3 @@ describe('POST /api/agent-workspaces/[workspaceId]/conversations/[conversationId
expect(mockCheckSessionAccess).not.toHaveBeenCalled();
});
});

describe('the workspace is waiting for the backfill', () => {
/**
* 503, not 404 and not 200. The conversation exists and is the caller's; the
* SERVER has not migrated this workspace yet, and it becomes ready when an
* operator runs the backfill — which is what 503 says and what nothing else on
* this route does.
*
* The status is asserted rather than assumed because this route has no
* exhaustiveness check: an adversarial review found that deleting the 503
* block lets the request fall through to `200 OK`, reporting success for a
* write the server refused.
*/
it('answers 503 and names the reason', async () => {
mockReopenConversationInSession.mockResolvedValue('awaiting_backfill');
const response = await post();
expect(response.status).toBe(503);
expect(await response.json()).toMatchObject({ code: 'awaiting_backfill' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,6 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
}

if (outcome === 'awaiting_backfill') {
// 503, matching `POST /nodes`. The conversation exists and is the caller's;
// the SERVER has not migrated this workspace yet and no action by the
// caller changes that. A 404 here would be a lie about a thread the user
// can see, and would send them looking for it.
return NextResponse.json(
{ error: 'This session is not ready yet. Its data is still being migrated.', code: 'awaiting_backfill' },
{ status: 503 },
);
}

if (outcome === 'session_full') {
// 409, not 404: the thread exists and is the caller's, and the workspace is
// simply full. This is the one refusal on this path a user can resolve —
Expand All @@ -92,6 +81,13 @@ export async function POST(request: Request, context: RouteContext) {
});
}

// EXHAUSTIVENESS BACKSTOP — see the twin in `claim/route.ts`. Everything
// below is a SUCCESS response, so a refusal reaching it is answered
// `200 {ok: true}`. An `if`-chain cannot be checked for completeness, so the
// narrowing is written down: adding a refusal to `ReopenConversationOutcome`
// without an arm above is a COMPILE error here rather than a silent success.
const settled: 'reopened' | 'already_open' = outcome;

// `alreadyOpen` (outcome === 'already_open'): this call did NOT transition
// the listing — it was already open (e.g. a different pane, tab, or an
// agent switch that left it open but unshown). The caller needs this to
Expand All @@ -100,5 +96,5 @@ export async function POST(request: Request, context: RouteContext) {
// listing the request never actually opened, possibly still in use
// elsewhere (review finding — chatgpt-codex-connector on PR #2299,
// round 15).
return NextResponse.json({ ok: true, alreadyOpen: outcome === 'already_open' });
return NextResponse.json({ ok: true, alreadyOpen: settled === 'already_open' });
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,23 +170,6 @@ describe('the answers', () => {
expect((await post({ baseRev: 4, put: [], drop: [] })).status).toBe(400);
});

it('503 while the backfill has not reached this workspace — the SERVER is unready, not the write', async () => {
// Not 400: the write was valid and nothing the caller changes would help.
// Not 409 either, which invites a rebase-and-retry against a tree that must
// not be written at all — so this carries no snapshot. It stops being true
// when the backfill runs, which is exactly what 503 says.
mockApply.mockResolvedValue({
status: 'refused',
code: 'awaiting_backfill',
detail: 'this workspace still holds membership that only the previous model records',
});
const response = await post({ baseRev: 4, put: [], drop: [] });
expect(response.status).toBe(503);
const body = await response.json();
expect(body).toMatchObject({ code: 'awaiting_backfill' });
expect(body).not.toHaveProperty('nodes');
});

it('403 for a binding the caller may not make, and says nothing about which', async () => {
mockApply.mockResolvedValue({
status: 'refused',
Expand Down
13 changes: 0 additions & 13 deletions apps/web/src/app/api/agent-workspaces/[workspaceId]/nodes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,19 +121,6 @@ export async function POST(request: Request, context: RouteContext) {
if (result.code === 'forbidden_target') {
return NextResponse.json({ error: result.detail }, { status: 403 });
}
// 503, because nothing is wrong with the write and nothing the caller can
// change would help: this database still holds membership only the previous
// model records, and writing would strand it permanently (see
// `awaitsBackfill`). It is the SERVER that is not ready, and
// it stops being unready when the backfill runs — which is what 503 means
// and what neither 400 nor 409 does. A client must not treat it as a
// rebase-and-retry, so it carries no snapshot.
if (result.code === 'awaiting_backfill') {
return NextResponse.json(
{ error: 'This workspace is not ready yet. Its data is still being migrated.', code: result.code },
{ status: 503 },
);
}
return NextResponse.json(
{ error: 'That write would not leave a valid workspace', code: result.code, detail: result.detail },
{ status: 400 },
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/app/api/agent-workspaces/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,8 @@ export async function GET(request: Request) {
* session's first thing, instead of minting a brand-new one —
* `claimConversationInSession` (`claim-conversation-in-workspace.ts`), which
* ADMITS the thread — one node in `agent_workspace_nodes`, which is what
* membership is. It used to write `conversations.workspaceId`; nothing writes
* that column now, and it survives only until the follow-up migration drops
* it. `driveId`/`agentPageId`
* membership is. It used to write `conversations.workspaceId`, a column
* migration 0256 dropped. `driveId`/`agentPageId`
* are derived from the claimed row itself (a `type: 'page'` row's own agent;
* a `type: 'global'` row takes the caller's `driveId`, same three-shape
* ambiguity as the ordinary mint path below) — a caller-supplied
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,7 @@ const conversationRow = (overrides: Record<string, unknown>) => ({
title: null,
type: 'page',
contextId: THIS_PAGE,
workspaceId: null,
agentPageId: null,
closedInWorkspaceAt: null,
rev: 0,
lastMessageAt: null,
createdAt: new Date(),
Expand Down
4 changes: 0 additions & 4 deletions apps/web/src/app/api/ai/global/[id]/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,6 @@ const mockConversation = (overrides: Partial<{
createdAt: Date;
updatedAt: Date;
isActive: boolean;
workspaceId: string | null;
closedInWorkspaceAt: Date | null;
}> = {}) => ({
id: overrides.id ?? mockConversationId,
userId: overrides.userId ?? mockUserId,
Expand All @@ -99,8 +97,6 @@ const mockConversation = (overrides: Partial<{
createdAt: overrides.createdAt ?? new Date(),
updatedAt: overrides.updatedAt ?? new Date(),
isActive: overrides.isActive ?? true,
workspaceId: overrides.workspaceId ?? null,
closedInWorkspaceAt: overrides.closedInWorkspaceAt ?? null,
});

const createContext = (id: string) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ const mockConversation = (overrides: Partial<{
lastMessageAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
workspaceId: null,
closedInWorkspaceAt: null,
});

const mockMessage = (overrides: Partial<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ describe('POST /api/ai/global/[id]/messages — lifecycle handoff', () => {
const newConv = {
id: 'conv-1', userId: 'user-1', title: null, type: 'global',
contextId: null, isActive: true, isShared: false,
workspaceId: null, closedInWorkspaceAt: null, agentPageId: null, rev: 0, planPageId: null,
agentPageId: null, rev: 0, planPageId: null,
createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), lastMessageAt: null,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ const mockConversation = (overrides: Partial<{
lastMessageAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
workspaceId: null,
closedInWorkspaceAt: null,
});

const mockUsageLog = (overrides: Partial<{
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/app/api/ai/global/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ const mockConversation = (overrides: Partial<{
createdAt: overrides.createdAt ?? new Date(),
updatedAt: overrides.updatedAt ?? new Date(),
isActive: overrides.isActive ?? true,
workspaceId: null,
closedInWorkspaceAt: null,
});

const createGetRequest = () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const mockAgent = () => ({
});

const mockConversationRow = (
overrides: Partial<{ userId: string; isShared: boolean; workspaceId: string | null; closedInWorkspaceAt: Date | null; isActive: boolean }> = {},
overrides: Partial<{ userId: string; isShared: boolean; isActive: boolean }> = {},
) => ({
id: mockConversationId,
userId: mockUserId,
Expand All @@ -125,8 +125,7 @@ const mockConversationRow = (
title: null,
isActive: true,
isShared: false,
workspaceId: null,
closedInWorkspaceAt: null, agentPageId: null, rev: 0,
agentPageId: null, rev: 0,
planPageId: null,
lastMessageAt: null,
createdAt: new Date('2025-01-01'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ const mockConversationRow = (overrides: Partial<{ userId: string; isShared: bool
title: null,
isActive: true,
isShared: false,
workspaceId: null,
closedInWorkspaceAt: null, agentPageId: null, rev: 0,
agentPageId: null, rev: 0,
planPageId: null,
lastMessageAt: null,
createdAt: new Date('2025-01-01'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,6 @@ describe('POST /api/v1/chat/completions — back-fill tool results', () => {
planPageId: null,
type: 'page',
lastMessageAt: null,
// Dead membership columns, present until the follow-up migration drops
// them. Nothing writes them; a node in `agent_workspace_nodes` is the
// membership now.
workspaceId: null,
closedInWorkspaceAt: null,
});

const fullHistory = [
Expand Down Expand Up @@ -337,11 +332,6 @@ describe('POST /api/v1/chat/completions — back-fill tool results', () => {
planPageId: null,
type: 'page',
lastMessageAt: null,
// Dead membership columns, present until the follow-up migration drops
// them. Nothing writes them; a node in `agent_workspace_nodes` is the
// membership now.
workspaceId: null,
closedInWorkspaceAt: null,
});

// OpenAI-format messages with no `id` fields, just like pagespace-cli sends
Expand Down Expand Up @@ -388,11 +378,6 @@ describe('POST /api/v1/chat/completions — back-fill tool results', () => {
planPageId: null,
type: 'page',
lastMessageAt: null,
// Dead membership columns, present until the follow-up migration drops
// them. Nothing writes them; a node in `agent_workspace_nodes` is the
// membership now.
workspaceId: null,
closedInWorkspaceAt: null,
});

const fullHistory = [
Expand Down
Loading