ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
ForbiddenException,
|
|
Get,
|
|
HttpCode,
|
|
HttpException,
|
|
HttpStatus,
|
|
Inject,
|
|
NotFoundException,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import type { Brain } from '@mosaicstack/brain';
|
|
import { BRAIN } from '../brain/brain.tokens.js';
|
|
import { AuthGuard } from '../auth/auth.guard.js';
|
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
|
import {
|
|
CreateConversationDto,
|
|
UpdateConversationDto,
|
|
SendMessageDto,
|
|
SearchMessagesDto,
|
|
} from './conversations.dto.js';
|
|
|
|
/**
|
|
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
|
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
|
|
* the repository is touched — never a duplicate write. The `code` field is exposed at the top level
|
|
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
|
|
*/
|
|
class HarnessRuntimeWriteUnsupportedException extends HttpException {
|
|
readonly code = 'runtime_unsupported' as const;
|
|
|
|
constructor() {
|
|
super(
|
|
{
|
|
code: 'runtime_unsupported',
|
|
message:
|
|
'Conversation message writes are handled by the harness runtime on this deployment.',
|
|
},
|
|
HttpStatus.SERVICE_UNAVAILABLE,
|
|
);
|
|
}
|
|
}
|
|
|
|
@Controller('api/conversations')
|
|
@UseGuards(AuthGuard)
|
|
export class ConversationsController {
|
|
/**
|
|
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
|
|
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
|
|
* request time — a single source of truth, so the controller cannot disagree with the router
|
|
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
|
|
* so this class depends on nothing else the router exposes.
|
|
*/
|
|
constructor(
|
|
@Inject(BRAIN) private readonly brain: Brain,
|
|
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
|
|
) {}
|
|
|
|
@Get()
|
|
async list(@CurrentUser() user: { id: string }) {
|
|
return this.brain.conversations.findAll(user.id);
|
|
}
|
|
|
|
@Get('search')
|
|
async search(@Query() dto: SearchMessagesDto, @CurrentUser() user: { id: string }) {
|
|
if (!dto.q || dto.q.trim().length === 0) {
|
|
throw new BadRequestException('Query parameter "q" is required and must not be empty');
|
|
}
|
|
const limit = dto.limit ?? 20;
|
|
const offset = dto.offset ?? 0;
|
|
return this.brain.conversations.searchMessages(user.id, dto.q.trim(), limit, offset);
|
|
}
|
|
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
|
const conversation = await this.brain.conversations.findById(id, user.id);
|
|
if (!conversation) throw new NotFoundException('Conversation not found');
|
|
return conversation;
|
|
}
|
|
|
|
@Post()
|
|
async create(@CurrentUser() user: { id: string }, @Body() dto: CreateConversationDto) {
|
|
return this.brain.conversations.create({
|
|
userId: user.id,
|
|
title: dto.title,
|
|
projectId: dto.projectId,
|
|
});
|
|
}
|
|
|
|
@Patch(':id')
|
|
async update(
|
|
@Param('id') id: string,
|
|
@Body() dto: UpdateConversationDto,
|
|
@CurrentUser() user: { id: string },
|
|
) {
|
|
const conversation = await this.brain.conversations.update(id, user.id, dto);
|
|
if (!conversation) throw new NotFoundException('Conversation not found');
|
|
return conversation;
|
|
}
|
|
|
|
@Delete(':id')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
|
const deleted = await this.brain.conversations.remove(id, user.id);
|
|
if (!deleted) throw new NotFoundException('Conversation not found');
|
|
}
|
|
|
|
@Get(':id/messages')
|
|
async listMessages(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
|
// Verify ownership explicitly to return a clear 404 rather than an empty list.
|
|
const conversation = await this.brain.conversations.findById(id, user.id);
|
|
if (!conversation) throw new NotFoundException('Conversation not found');
|
|
return this.brain.conversations.findMessages(id, user.id);
|
|
}
|
|
|
|
@Post(':id/messages')
|
|
async addMessage(
|
|
@Param('id') id: string,
|
|
@Body() dto: SendMessageDto,
|
|
@CurrentUser() user: { id: string },
|
|
) {
|
|
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
|
|
// harness path owns persistence there, so a direct write would duplicate the message. The mode
|
|
// comes from the router's init-time resolution, not a request-time env read.
|
|
if (this.router.runtimeMode === 'pi-rpc') {
|
|
throw new HarnessRuntimeWriteUnsupportedException();
|
|
}
|
|
|
|
const message = await this.brain.conversations.addMessage(
|
|
{
|
|
conversationId: id,
|
|
role: dto.role,
|
|
content: dto.content,
|
|
metadata: dto.metadata,
|
|
},
|
|
user.id,
|
|
);
|
|
if (!message) throw new ForbiddenException('Conversation not found or access denied');
|
|
return message;
|
|
}
|
|
}
|