1import { Inject, Injectable } from "@nestjs/common";
2import type { Prisma } from "@prisma/client";
3import { PrismaService } from "../../common/prisma.service.js";
4import { AuditService } from "../../common/audit.service.js";
5import { ApiError } from "../../common/errors.js";
6import { loadEnv } from "../../config/env.js";
7import { armMockAssistantSeam, armMockDbInvalidStructure, armMockFailures, armMockGenerateSeam, armMockUnknownBlockId, resetWorkspaceCache } from "../ai/gateway-provider.js";
8import type { AiUsageLimitsInput, MockAssistantInput, MockDbInvalidInput, MockFailInput, MockGenerateSeamInput, MockUnknownBlockIdInput } from "./ai-test-seam.dto.js";
9
10
40@Injectable()
41export class AiTestSeamService {
42
43
44 constructor(
45 @Inject(PrismaService) private readonly prisma: PrismaService,
46 @Inject(AuditService) private readonly audit: AuditService
47 ) {}
48
49
50 async armMockOutage(workspaceId: string, input: MockFailInput) {
51 this.assertSeamEnabled();
52 if (loadEnv().AI_PROVIDER !== "mock") {
53
54
55
56 throw ApiError.unprocessable("Mock-outage arming requires AI_PROVIDER=mock.", [
57 { reason: "ai_provider_not_mock" }
58 ]);
59 }
60 const status = armMockFailures(workspaceId, input.status, input.deltas);
61 await this.audit.write({
62 action: "workspace.ai_test_seam_mock_outage_armed",
63 resourceType: "ai",
64 resourceId: "mock-outage",
65 workspaceId,
66 metadata: { status, deltas: input.deltas, seam: true }
67 });
68 return { armed: true, workspaceId, status, deltas: input.deltas };
69 }
70
71
72 async setAiUsageLimits(workspaceId: string, input: AiUsageLimitsInput) {
73 this.assertSeamEnabled();
74 const ws = await this.prisma.workspace.findUnique({
75 where: { id: workspaceId },
76 select: { id: true, deletedAt: true, usageLimits: true }
77 });
78 if (!ws || ws.deletedAt) throw ApiError.notFound("Workspace not found.");
79 const current = (ws.usageLimits ?? {}) as Record<string, unknown>;
80 const next = { ...current };
81 if (input.aiMonthlyTokens === null) {
82 delete next.aiMonthlyTokens;
83 } else {
84 next.aiMonthlyTokens = input.aiMonthlyTokens;
85 }
86 await this.prisma.workspace.update({
87 where: { id: workspaceId },
88 data: { usageLimits: next as Prisma.InputJsonValue }
89 });
90
91
92 resetWorkspaceCache();
93 await this.audit.write({
94 action: "workspace.ai_test_seam_usage_limits_set",
95 resourceType: "workspace",
96 resourceId: workspaceId,
97 workspaceId,
98 metadata: { aiMonthlyTokens: input.aiMonthlyTokens, seam: true }
99 });
100 return { workspaceId, usageLimits: next };
101 }
102
103
107 async armMockUnknownBlockId(workspaceId: string, input: MockUnknownBlockIdInput) {
108 this.assertSeamEnabled();
109 if (loadEnv().AI_PROVIDER !== "mock") {
110 throw ApiError.unprocessable("Unknown-blockId arming requires AI_PROVIDER=mock.", [
111 { reason: "ai_provider_not_mock" }
112 ]);
113 }
114 armMockUnknownBlockId(workspaceId, input.unknownId);
115 await this.audit.write({
116 action: "workspace.ai_test_seam_unknown_block_id_armed",
117 resourceType: "ai",
118 resourceId: "mock-unknown-id",
119 workspaceId,
120 metadata: { unknownId: input.unknownId, seam: true }
121 });
122 return { armed: true, workspaceId, unknownId: input.unknownId };
123 }
124
125
130 async armMockDbInvalid(workspaceId: string, input: MockDbInvalidInput) {
131 this.assertSeamEnabled();
132 if (loadEnv().AI_PROVIDER !== "mock") {
133 throw ApiError.unprocessable("Database-invalid arming requires AI_PROVIDER=mock.", [
134 { reason: "ai_provider_not_mock" }
135 ]);
136 }
137 armMockDbInvalidStructure(workspaceId, input.action);
138 await this.audit.write({
139 action: "workspace.ai_test_seam_db_invalid_armed",
140 resourceType: "ai",
141 resourceId: "mock-db-invalid",
142 workspaceId,
143 metadata: { action: input.action, seam: true }
144 });
145 return { armed: true, workspaceId, action: input.action };
146 }
147
148
153 async armMockGenerateSeam(workspaceId: string, input: MockGenerateSeamInput) {
154 this.assertSeamEnabled();
155 if (loadEnv().AI_PROVIDER !== "mock") {
156 throw ApiError.unprocessable("Generate-seam arming requires AI_PROVIDER=mock.", [
157 { reason: "ai_provider_not_mock" }
158 ]);
159 }
160 const arming = armMockGenerateSeam(workspaceId, input);
161 await this.audit.write({
162 action: "workspace.ai_test_seam_generate_seam_armed",
163 resourceType: "ai",
164 resourceId: "mock-generate",
165 workspaceId,
166 metadata: { failStep: input.failStep ?? null, slowMs: input.slowMs ?? null, seam: true }
167 });
168 return { armed: true, workspaceId, ...arming };
169 }
170
171
175 async armMockAssistantSeam(workspaceId: string, input: MockAssistantInput) {
176 this.assertSeamEnabled();
177 if (loadEnv().AI_PROVIDER !== "mock") {
178 throw ApiError.unprocessable("Assistant-seam arming requires AI_PROVIDER=mock.", [
179 { reason: "ai_provider_not_mock" }
180 ]);
181 }
182 armMockAssistantSeam(workspaceId, input);
183 await this.audit.write({
184 action: "workspace.ai_test_seam_assistant_armed",
185 resourceType: "ai",
186 resourceId: "mock-assistant",
187 workspaceId,
188 metadata: { hostile: input.hostile, forgeId: input.forgeId, seam: true }
189 });
190 return { armed: true, workspaceId, ...input };
191 }
192
193
194 private assertSeamEnabled(): void {
195 if (loadEnv().INTERNAL_TEST_SEAM !== "1") {
196 throw ApiError.unauthorized("Test seam is disabled.", [{ reason: "test_seam_disabled" }]);
197 }
198 }
199}
200