refactor(chat): route browser chat through one runtime (P3 Slice-Zero Task 5) (#1172)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
This commit was merged in pull request #1172.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
|
||||
import { ConversationsController } from './conversations.controller.js';
|
||||
|
||||
/**
|
||||
* Task 5 harness fence for the conversations REST write path.
|
||||
*
|
||||
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
|
||||
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
|
||||
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
|
||||
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
|
||||
*
|
||||
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
|
||||
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
|
||||
* `process.env` at request time. The two "env is flipped after construction" tests below are the
|
||||
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
|
||||
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
|
||||
*/
|
||||
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const USER = { id: 'user-1' };
|
||||
|
||||
function sendMessageDto() {
|
||||
return {
|
||||
role: 'user' as const,
|
||||
content: 'hello from the legacy REST write path',
|
||||
metadata: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function brainWithMessageSpy() {
|
||||
const addMessage = vi.fn().mockResolvedValue({
|
||||
id: 'message-1',
|
||||
conversationId: CONVERSATION_ID,
|
||||
role: 'user',
|
||||
content: 'hello from the legacy REST write path',
|
||||
});
|
||||
return {
|
||||
brain: { conversations: { addMessage } } as never,
|
||||
addMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
|
||||
function routerFixedTo(mode: ChatRuntimeMode) {
|
||||
return { runtimeMode: mode };
|
||||
}
|
||||
|
||||
let priorMode: string | undefined;
|
||||
|
||||
describe('conversations REST write path — Task 5 harness fence', () => {
|
||||
beforeEach(() => {
|
||||
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
|
||||
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
|
||||
});
|
||||
|
||||
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||
|
||||
await expect(
|
||||
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||
|
||||
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
|
||||
// written, so no duplicate message can be produced.
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||
|
||||
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||
|
||||
expect(addMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
conversationId: CONVERSATION_ID,
|
||||
role: 'user',
|
||||
content: 'hello from the legacy REST write path',
|
||||
metadata: undefined,
|
||||
},
|
||||
USER.id,
|
||||
);
|
||||
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
|
||||
});
|
||||
|
||||
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
|
||||
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
|
||||
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
|
||||
|
||||
await expect(
|
||||
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
|
||||
).rejects.toMatchObject({ code: 'runtime_unsupported' });
|
||||
|
||||
expect(addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
|
||||
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
|
||||
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
|
||||
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
||||
const { brain, addMessage } = brainWithMessageSpy();
|
||||
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
|
||||
|
||||
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
|
||||
|
||||
expect(addMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
@@ -19,6 +20,7 @@ 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,
|
||||
@@ -26,10 +28,41 @@ import {
|
||||
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 {
|
||||
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
|
||||
/**
|
||||
* `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 }) {
|
||||
@@ -94,6 +127,13 @@ export class ConversationsController {
|
||||
@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,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { ConversationsController } from './conversations.controller.js';
|
||||
|
||||
/**
|
||||
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
|
||||
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
|
||||
* router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ChatModule],
|
||||
controllers: [ConversationsController],
|
||||
})
|
||||
export class ConversationsModule {}
|
||||
|
||||
Reference in New Issue
Block a user