@taodigital/app-brain 0.0.0-stage → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/dist/archetype-classification.d.ts +54 -0
- package/dist/archetype-classification.js +95 -0
- package/dist/cloud-storage.d.ts +106 -0
- package/dist/cloud-storage.js +539 -0
- package/dist/email.d.ts +21 -0
- package/dist/email.js +234 -0
- package/dist/grounding.d.ts +30 -0
- package/dist/grounding.js +138 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +37 -0
- package/dist/nav-graph.d.ts +42 -0
- package/dist/nav-graph.js +248 -0
- package/dist/notifications.d.ts +26 -0
- package/dist/notifications.js +105 -0
- package/dist/orphan-classification.d.ts +32 -0
- package/dist/orphan-classification.js +40 -0
- package/dist/repository.d.ts +3185 -0
- package/dist/repository.js +5927 -0
- package/dist/run-notifications.d.ts +16 -0
- package/dist/run-notifications.js +244 -0
- package/dist/skill-file-kinds.d.ts +5 -0
- package/dist/skill-file-kinds.js +50 -0
- package/dist/token-ledger.d.ts +23 -0
- package/dist/token-ledger.js +178 -0
- package/dist/traceability.d.ts +213 -0
- package/dist/traceability.js +339 -0
- package/package.json +24 -4
- package/src/archetype-classification.test.ts +91 -0
- package/src/archetype-classification.ts +132 -0
- package/src/cloud-storage.test.ts +83 -0
- package/src/cloud-storage.ts +627 -0
- package/src/email.ts +268 -0
- package/src/grounding.ts +172 -0
- package/src/index.ts +18 -0
- package/src/nav-graph.ts +295 -0
- package/src/notifications.ts +156 -0
- package/src/orphan-classification.test.ts +61 -0
- package/src/orphan-classification.ts +65 -0
- package/src/orphan-screens.test.ts +88 -0
- package/src/repository.ts +7397 -0
- package/src/run-notifications.ts +281 -0
- package/src/skill-file-kinds.ts +51 -0
- package/src/token-ledger.ts +209 -0
- package/src/traceability.test.ts +75 -0
- package/src/traceability.ts +477 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { dispatchWebhook, sendSlackTestRunNotification } from '@taodigital/core';
|
|
2
|
+
import { AppBrain } from './repository.js';
|
|
3
|
+
import { dispatchNotification, dispatchToTenantAdmins } from './notifications.js';
|
|
4
|
+
import { sendEmail } from './email.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single, reliable trigger point for everything that should happen when a
|
|
8
|
+
* TestRun finishes: admin in-app/email notification, generic webhook,
|
|
9
|
+
* suite-owner failure emails, GitHub check-run, Slack, Teams. Call this from
|
|
10
|
+
* wherever completeTestRun()/failTestRun() is actually called — for both the
|
|
11
|
+
* interactive web-driven flow (packages/api/src/routes/runs.ts) and the
|
|
12
|
+
* CI-triggered worker flow (packages/crawler/src/job-queue.ts's
|
|
13
|
+
* processRunTestSuiteJob) — NOT from the SSE stream's polling loop, which
|
|
14
|
+
* only runs while a browser has that run's page open and would otherwise
|
|
15
|
+
* silently drop notifications for any run that completes with nobody
|
|
16
|
+
* watching (the common case for CI-triggered runs).
|
|
17
|
+
*/
|
|
18
|
+
export async function notifyRunCompletion(
|
|
19
|
+
tenantId: string,
|
|
20
|
+
projectId: string,
|
|
21
|
+
runId: string,
|
|
22
|
+
status: 'COMPLETED' | 'FAILED',
|
|
23
|
+
summary: Record<string, any>,
|
|
24
|
+
results: Array<{ testCaseId: string; status: string }> = [],
|
|
25
|
+
) {
|
|
26
|
+
const runEvent = status === 'COMPLETED' ? 'test.run.done' : 'test.run.failed';
|
|
27
|
+
dispatchToTenantAdmins(tenantId, {
|
|
28
|
+
type: runEvent,
|
|
29
|
+
title: status === 'COMPLETED' ? 'Test run completed' : 'Test run failed',
|
|
30
|
+
body: status === 'COMPLETED'
|
|
31
|
+
? `Test run finished. Check results in the dashboard.`
|
|
32
|
+
: `Test run failed. Review logs for details.`,
|
|
33
|
+
metadata: { runId },
|
|
34
|
+
}).catch(console.error);
|
|
35
|
+
dispatchWebhook(tenantId, projectId, runEvent, {
|
|
36
|
+
runId,
|
|
37
|
+
passed: summary.passed ?? 0,
|
|
38
|
+
failed: summary.failed ?? 0,
|
|
39
|
+
total: summary.total ?? 0,
|
|
40
|
+
}).catch(console.error);
|
|
41
|
+
notifySuiteOwners(tenantId, runId, results).catch(() => {});
|
|
42
|
+
postGitHubCheckRun(tenantId, projectId, runId, status, summary).catch(() => {});
|
|
43
|
+
postSlackTestRunResult(tenantId, projectId, runId, status, summary).catch(() => {});
|
|
44
|
+
postTeamsNotification(tenantId, projectId, runId, status, summary).catch(() => {});
|
|
45
|
+
if (status === 'FAILED') notifyAlertEmails(tenantId, runId, summary).catch(() => {});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Workspace Settings > Notifications > Alert Emails — arbitrary external addresses
|
|
49
|
+
// with no User row, so this bypasses dispatchToTenantAdmins/NotificationPreference
|
|
50
|
+
// (which require one) and calls the mailer directly, one send per address. Only
|
|
51
|
+
// fires on FAILED, matching that field's own UI copy ("notify when critical errors
|
|
52
|
+
// occur") — a passing run isn't the kind of thing this list exists to catch.
|
|
53
|
+
async function notifyAlertEmails(tenantId: string, runId: string, summary: Record<string, any>) {
|
|
54
|
+
const cfg = await AppBrain.getTenantNotificationsConfig(tenantId).catch(() => null);
|
|
55
|
+
const emails = cfg?.alertEmails ?? [];
|
|
56
|
+
if (emails.length === 0) return;
|
|
57
|
+
|
|
58
|
+
const failed = summary.failed ?? 0;
|
|
59
|
+
const total = summary.total ?? 0;
|
|
60
|
+
await Promise.all(emails.map((to) => sendEmail({
|
|
61
|
+
to,
|
|
62
|
+
type: 'test_run_failed',
|
|
63
|
+
title: `${process.env.EMAIL_BRAND_NAME ?? 'ZeTa-AI'} test run failed`,
|
|
64
|
+
body: `A test run failed: ${failed}/${total} test cases failed. Run ID: ${runId}.`,
|
|
65
|
+
metadata: { runId, tenantId },
|
|
66
|
+
tenantId,
|
|
67
|
+
}).catch(() => {})));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function notifySuiteOwners(
|
|
71
|
+
tenantId: string,
|
|
72
|
+
runId: string,
|
|
73
|
+
results: Array<{ testCaseId: string; status: string }>,
|
|
74
|
+
) {
|
|
75
|
+
const failedCaseIds = [...new Set(
|
|
76
|
+
results.filter(r => r.status === 'FAILED' || r.status === 'FLAKY').map(r => r.testCaseId)
|
|
77
|
+
)];
|
|
78
|
+
if (!failedCaseIds.length) return;
|
|
79
|
+
|
|
80
|
+
const { prisma } = await import('@taodigital/database');
|
|
81
|
+
|
|
82
|
+
const cases = await prisma.testCase.findMany({
|
|
83
|
+
where: { id: { in: failedCaseIds }, tenantId },
|
|
84
|
+
select: { suiteId: true },
|
|
85
|
+
});
|
|
86
|
+
const suiteIds = [...new Set((cases as any[]).map((c: any) => c.suiteId))];
|
|
87
|
+
|
|
88
|
+
const suites = await prisma.testSuite.findMany({
|
|
89
|
+
where: { id: { in: suiteIds }, tenantId, ownerUserId: { not: null } },
|
|
90
|
+
select: { id: true, name: true, ownerUserId: true },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Batch-load all suite owners in one query instead of N+1 per suite
|
|
94
|
+
const ownerIds = (suites as any[]).map((s: any) => s.ownerUserId).filter(Boolean) as string[];
|
|
95
|
+
const owners = ownerIds.length > 0
|
|
96
|
+
? await prisma.user.findMany({
|
|
97
|
+
where: { id: { in: ownerIds } },
|
|
98
|
+
select: { id: true, email: true, name: true },
|
|
99
|
+
}).catch(() => [] as { id: string; email: string; name: string | null }[])
|
|
100
|
+
: [];
|
|
101
|
+
const ownerMap = new Map((owners as any[]).map((u: any) => [u.id, u]));
|
|
102
|
+
|
|
103
|
+
await Promise.all((suites as any[]).map(async (suite: any) => {
|
|
104
|
+
if (!suite.ownerUserId) return;
|
|
105
|
+
const user = ownerMap.get(suite.ownerUserId) ?? null;
|
|
106
|
+
dispatchNotification({
|
|
107
|
+
tenantId,
|
|
108
|
+
userId: suite.ownerUserId,
|
|
109
|
+
type: 'test.run.failed',
|
|
110
|
+
title: `Test failures in suite "${suite.name}"`,
|
|
111
|
+
body: `Tests you own in "${suite.name}" have failures. Run ID: ${runId}`,
|
|
112
|
+
metadata: { runId, suiteId: suite.id },
|
|
113
|
+
...(user ? { email: { recipientEmail: (user as any).email, recipientName: (user as any).name ?? undefined } } : {}),
|
|
114
|
+
}).catch(console.error);
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function postGitHubCheckRun(
|
|
119
|
+
tenantId: string,
|
|
120
|
+
projectId: string,
|
|
121
|
+
runId: string,
|
|
122
|
+
status: 'COMPLETED' | 'FAILED',
|
|
123
|
+
summary: Record<string, any>,
|
|
124
|
+
) {
|
|
125
|
+
try {
|
|
126
|
+
// Only post if GitHub connector with repo is configured
|
|
127
|
+
const { withTenant } = await import('@taodigital/database');
|
|
128
|
+
const connector = await withTenant(tenantId, (tx) =>
|
|
129
|
+
tx.connector.findFirst({ where: { tenantId, projectId, kind: 'GITHUB' } })
|
|
130
|
+
);
|
|
131
|
+
if (!connector) return;
|
|
132
|
+
|
|
133
|
+
const { decrypt } = await import('@taodigital/core');
|
|
134
|
+
const cfg = (connector as any).config as Record<string, any> ?? {};
|
|
135
|
+
const token = cfg.token_enc ? decrypt(cfg.token_enc) : '';
|
|
136
|
+
const repo = cfg.repo as string | undefined;
|
|
137
|
+
if (!token || !repo) return;
|
|
138
|
+
|
|
139
|
+
// Get the project's default branch and latest commit SHA
|
|
140
|
+
const repoRes = await fetch(`https://api.github.com/repos/${repo}`, {
|
|
141
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
|
|
142
|
+
});
|
|
143
|
+
if (!repoRes.ok) return;
|
|
144
|
+
const repoData = await repoRes.json() as { default_branch: string };
|
|
145
|
+
const branchRes = await fetch(`https://api.github.com/repos/${repo}/branches/${repoData.default_branch}`, {
|
|
146
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
|
|
147
|
+
});
|
|
148
|
+
if (!branchRes.ok) return;
|
|
149
|
+
const branchData = await branchRes.json() as { commit: { sha: string } };
|
|
150
|
+
|
|
151
|
+
const conclusion = status === 'COMPLETED' ? 'success' : 'failure';
|
|
152
|
+
const passed = summary.passed ?? 0;
|
|
153
|
+
const failed = summary.failed ?? 0;
|
|
154
|
+
const total = summary.total ?? 0;
|
|
155
|
+
|
|
156
|
+
await fetch(`https://api.github.com/repos/${repo}/check-runs`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: {
|
|
159
|
+
Authorization: `Bearer ${token}`,
|
|
160
|
+
Accept: 'application/vnd.github+json',
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
name: 'ZeTa Test Run',
|
|
165
|
+
head_sha: branchData.commit.sha,
|
|
166
|
+
status: 'completed',
|
|
167
|
+
conclusion,
|
|
168
|
+
completed_at: new Date().toISOString(),
|
|
169
|
+
output: {
|
|
170
|
+
title: conclusion === 'success' ? `${passed}/${total} tests passed` : `${failed} test(s) failed`,
|
|
171
|
+
summary: `**Passed:** ${passed} | **Failed:** ${failed} | **Total:** ${total}\n\nRun ID: \`${runId}\``,
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
} catch { /* fire-and-forget, never block the run */ }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function postSlackTestRunResult(
|
|
179
|
+
tenantId: string,
|
|
180
|
+
projectId: string,
|
|
181
|
+
runId: string,
|
|
182
|
+
status: 'COMPLETED' | 'FAILED',
|
|
183
|
+
summary: Record<string, any>,
|
|
184
|
+
) {
|
|
185
|
+
try {
|
|
186
|
+
const passed = summary.passed ?? 0;
|
|
187
|
+
const failed = summary.failed ?? 0;
|
|
188
|
+
const total = summary.total ?? 0;
|
|
189
|
+
const slackStatus = status === 'COMPLETED' && failed === 0 ? 'PASS' : 'FAIL';
|
|
190
|
+
|
|
191
|
+
// Resolve the human-readable project name for the notification.
|
|
192
|
+
const { withTenant } = await import('@taodigital/database');
|
|
193
|
+
const project = await withTenant(tenantId, (tx) =>
|
|
194
|
+
tx.project.findUnique({ where: { id: projectId }, select: { name: true } })
|
|
195
|
+
).catch(() => null);
|
|
196
|
+
const projectName = (project as any)?.name ?? projectId;
|
|
197
|
+
|
|
198
|
+
const webBase = process.env.NEXT_PUBLIC_WEB_URL || 'http://localhost:3000';
|
|
199
|
+
const resultsUrl = `${webBase}/projects/${projectId}/runs/${runId}`;
|
|
200
|
+
|
|
201
|
+
await sendSlackTestRunNotification(tenantId, {
|
|
202
|
+
projectName,
|
|
203
|
+
runId,
|
|
204
|
+
status: slackStatus,
|
|
205
|
+
passed,
|
|
206
|
+
failed,
|
|
207
|
+
total,
|
|
208
|
+
resultsUrl,
|
|
209
|
+
});
|
|
210
|
+
} catch { /* fire-and-forget */ }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function postTeamsNotification(
|
|
214
|
+
tenantId: string,
|
|
215
|
+
projectId: string,
|
|
216
|
+
runId: string,
|
|
217
|
+
status: 'COMPLETED' | 'FAILED',
|
|
218
|
+
summary: Record<string, any>,
|
|
219
|
+
) {
|
|
220
|
+
try {
|
|
221
|
+
const connector = await AppBrain.getWorkspaceConnector(tenantId, 'TEAMS');
|
|
222
|
+
if (!connector?.config?.webhookUrl) return;
|
|
223
|
+
|
|
224
|
+
const passed = summary.passed ?? 0;
|
|
225
|
+
const failed = summary.failed ?? 0;
|
|
226
|
+
const total = summary.total ?? 0;
|
|
227
|
+
const ok = status === 'COMPLETED' && failed === 0;
|
|
228
|
+
const color = ok ? '00c853' : 'f44336';
|
|
229
|
+
const title = ok ? '✅ ZeTa: All tests passed' : '❌ ZeTa: Test run failed';
|
|
230
|
+
|
|
231
|
+
const payload = {
|
|
232
|
+
type: 'message',
|
|
233
|
+
attachments: [{
|
|
234
|
+
contentType: 'application/vnd.microsoft.card.adaptive',
|
|
235
|
+
content: {
|
|
236
|
+
type: 'AdaptiveCard',
|
|
237
|
+
version: '1.4',
|
|
238
|
+
body: [
|
|
239
|
+
{ type: 'TextBlock', text: title, weight: 'Bolder', size: 'Medium', color: ok ? 'Good' : 'Attention' },
|
|
240
|
+
{
|
|
241
|
+
type: 'FactSet',
|
|
242
|
+
facts: [
|
|
243
|
+
{ title: 'Passed', value: String(passed) },
|
|
244
|
+
{ title: 'Failed', value: String(failed) },
|
|
245
|
+
{ title: 'Total', value: String(total) },
|
|
246
|
+
{ title: 'Project', value: projectId },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
...(connector.config.baseUrl ? {
|
|
251
|
+
actions: [{
|
|
252
|
+
type: 'Action.OpenUrl',
|
|
253
|
+
title: 'View Run',
|
|
254
|
+
url: `${connector.config.baseUrl}/projects/${projectId}/sessions`,
|
|
255
|
+
}],
|
|
256
|
+
} : {}),
|
|
257
|
+
},
|
|
258
|
+
}],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const { assertResolvesToPublicAddress } = await import('@taodigital/core');
|
|
262
|
+
await assertResolvesToPublicAddress(connector.config.webhookUrl);
|
|
263
|
+
// redirect:'manual' — same SSRF-bypass concern as postSlackNotification above.
|
|
264
|
+
// Retry with exponential backoff on transient failures (429, 5xx).
|
|
265
|
+
let lastErr: unknown;
|
|
266
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
267
|
+
if (attempt > 0) await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
|
|
268
|
+
try {
|
|
269
|
+
const res = await fetch(connector.config.webhookUrl, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'Content-Type': 'application/json' },
|
|
272
|
+
body: JSON.stringify(payload),
|
|
273
|
+
redirect: 'manual',
|
|
274
|
+
});
|
|
275
|
+
if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) break;
|
|
276
|
+
lastErr = new Error(`HTTP ${res.status}`);
|
|
277
|
+
} catch (err) { lastErr = err; }
|
|
278
|
+
}
|
|
279
|
+
void lastErr; // fire-and-forget — log absorbed above
|
|
280
|
+
} catch { /* fire-and-forget */ }
|
|
281
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Single source of truth for SkillFile kind classifications. */
|
|
2
|
+
|
|
3
|
+
export const SKILL_FILE_GLOBAL_KINDS: string[] = [
|
|
4
|
+
// Platform intelligence (crawl-generated)
|
|
5
|
+
'TECH_STACK', 'AUTH_SESSION', 'BUSINESS_DOMAIN', 'DATA_MODEL',
|
|
6
|
+
'INTERACTION_PATTERNS', 'ERROR_CATALOGUE', 'API_CONTRACT',
|
|
7
|
+
'LOCATOR_STRATEGY', 'PERFORMANCE_BASELINE',
|
|
8
|
+
// Repo intelligence (repo-scan-generated)
|
|
9
|
+
'REPO_TEST_FRAMEWORK', 'REPO_CODING_CONVENTIONS', 'REPO_PAGE_OBJECTS', 'REPO_TEST_FILE_STRUCTURE',
|
|
10
|
+
// Framework standards (Zeta-specific)
|
|
11
|
+
'ZETA_FRAMEWORK_STANDARD', 'ZETA_FRAMEWORK_SELENIUM_JAVA',
|
|
12
|
+
// Testing intelligence (post-generation)
|
|
13
|
+
'COVERAGE_MATRIX', 'TEST_CASES', 'TEST_CASE', 'TEST_DATA', 'TEST_SCRIPT',
|
|
14
|
+
'REQUIREMENTS', 'REQUIREMENTS_FUNCTIONAL', 'REQUIREMENTS_NONFUNCTIONAL',
|
|
15
|
+
'REQUIREMENTS_SECURITY', 'REQUIREMENTS_BUSINESS_RULES',
|
|
16
|
+
'REQUIREMENTS_ACCEPTANCE_CRITERIA', 'REQUIREMENTS_TRACEABILITY',
|
|
17
|
+
'REQUIREMENTS_WORKFLOWS', 'REQUIREMENTS_ROLES_PERMISSIONS',
|
|
18
|
+
'REQUIREMENTS_DATA_RULES', 'REQUIREMENTS_INTEGRATIONS',
|
|
19
|
+
'REQUIREMENTS_COMPLIANCE_AUDIT', 'REQUIREMENTS_ACCESSIBILITY',
|
|
20
|
+
'REQUIREMENTS_PERFORMANCE',
|
|
21
|
+
'REQUIREMENTS_COVERAGE',
|
|
22
|
+
// Execution history
|
|
23
|
+
'TEST_EXECUTION_HISTORY', 'VISUAL_REGRESSION_HISTORY', 'FIELD_CATALOG',
|
|
24
|
+
// Healing
|
|
25
|
+
'HEALING_LOG',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const SKILL_FILE_SELECTIVE_KINDS: string[] = [
|
|
29
|
+
'COMPONENT_LIBRARY', 'ELEMENT_BEHAVIORS', 'VISUAL_BASELINE', 'NAVIGATION_MAP',
|
|
30
|
+
// Per-screen structural knowledge
|
|
31
|
+
'SCREEN_UNDERSTANDING',
|
|
32
|
+
// Per-screen actual locator inventory (extracted by extractLocatorsWithLLM)
|
|
33
|
+
'LOCATOR_INVENTORY',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const SKILL_FILE_SEARCH_FALLBACK_KINDS: string[] = [
|
|
37
|
+
'TECH_STACK', 'AUTH_SESSION', 'BUSINESS_DOMAIN', 'DATA_MODEL',
|
|
38
|
+
'INTERACTION_PATTERNS', 'ERROR_CATALOGUE', 'API_CONTRACT', 'REPO_TEST_FRAMEWORK',
|
|
39
|
+
'REQUIREMENTS', 'REQUIREMENTS_FUNCTIONAL', 'REQUIREMENTS_NONFUNCTIONAL',
|
|
40
|
+
'REQUIREMENTS_SECURITY', 'REQUIREMENTS_BUSINESS_RULES',
|
|
41
|
+
'REQUIREMENTS_ACCEPTANCE_CRITERIA', 'REQUIREMENTS_TRACEABILITY',
|
|
42
|
+
'REQUIREMENTS_WORKFLOWS', 'REQUIREMENTS_ROLES_PERMISSIONS',
|
|
43
|
+
'REQUIREMENTS_DATA_RULES', 'REQUIREMENTS_INTEGRATIONS',
|
|
44
|
+
'REQUIREMENTS_COMPLIANCE_AUDIT', 'REQUIREMENTS_ACCESSIBILITY',
|
|
45
|
+
'REQUIREMENTS_PERFORMANCE',
|
|
46
|
+
'REQUIREMENTS_COVERAGE',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const SKILL_FILE_CROSS_PROJECT_KINDS: string[] = [
|
|
50
|
+
'ELEMENT_BEHAVIORS', 'INTERACTION_PATTERNS',
|
|
51
|
+
];
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { withTenant } from '@taodigital/database';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import type { LLMUsage } from '@taodigital/core';
|
|
4
|
+
|
|
5
|
+
export class TokenBudgetExceededError extends Error {
|
|
6
|
+
constructor(message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'TokenBudgetExceededError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isPositiveLimit(value: number | null | undefined): value is number {
|
|
13
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isNewBudgetMonth(lastResetAt: Date, now: Date): boolean {
|
|
17
|
+
return lastResetAt.getMonth() !== now.getMonth()
|
|
18
|
+
|| lastResetAt.getFullYear() !== now.getFullYear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assertWithinBudget(args: {
|
|
22
|
+
monthlyTokenCap?: number | null;
|
|
23
|
+
monthlyUsdCap?: number | null;
|
|
24
|
+
currentMonthTokens: number;
|
|
25
|
+
currentMonthUsd: number;
|
|
26
|
+
additionalTokens?: number;
|
|
27
|
+
additionalUsd?: number;
|
|
28
|
+
}) {
|
|
29
|
+
const nextTokens = args.currentMonthTokens + Math.max(0, Math.ceil(args.additionalTokens ?? 0));
|
|
30
|
+
const nextUsd = args.currentMonthUsd + Math.max(0, args.additionalUsd ?? 0);
|
|
31
|
+
if (isPositiveLimit(args.monthlyTokenCap) && nextTokens > args.monthlyTokenCap) {
|
|
32
|
+
throw new TokenBudgetExceededError(
|
|
33
|
+
`Monthly token budget exceeded (${nextTokens.toLocaleString()} / ${args.monthlyTokenCap.toLocaleString()} tokens). Increase the workspace budget or wait until next month.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (isPositiveLimit(args.monthlyUsdCap) && nextUsd > args.monthlyUsdCap) {
|
|
37
|
+
throw new TokenBudgetExceededError(
|
|
38
|
+
`Monthly AI spend budget exceeded ($${nextUsd.toFixed(2)} / $${args.monthlyUsdCap.toFixed(2)}). Increase the workspace budget or wait until next month.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Preflight hard-stop used before starting new AI calls. */
|
|
44
|
+
export async function enforceTokenBudget(tenantId: string, estimatedTokens = 0, estimatedUsd = 0) {
|
|
45
|
+
await withTenant(tenantId, async (tx) => {
|
|
46
|
+
const budget = await tx.tokenBudget.findUnique({ where: { tenantId } });
|
|
47
|
+
if (!budget) return;
|
|
48
|
+
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const resetNeeded = isNewBudgetMonth(budget.lastResetAt, now);
|
|
51
|
+
const currentMonthTokens = resetNeeded ? 0 : budget.currentMonthTokens;
|
|
52
|
+
const currentMonthUsd = resetNeeded ? 0 : budget.currentMonthUsd;
|
|
53
|
+
if (resetNeeded) {
|
|
54
|
+
await tx.tokenBudget.update({
|
|
55
|
+
where: { tenantId },
|
|
56
|
+
data: { currentMonthTokens: 0, currentMonthUsd: 0, lastResetAt: now },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Soft pre-check: fast fail if clearly over budget.
|
|
61
|
+
assertWithinBudget({
|
|
62
|
+
monthlyTokenCap: budget.monthlyTokenCap,
|
|
63
|
+
monthlyUsdCap: budget.monthlyUsdCap,
|
|
64
|
+
currentMonthTokens,
|
|
65
|
+
currentMonthUsd,
|
|
66
|
+
additionalTokens: estimatedTokens,
|
|
67
|
+
additionalUsd: estimatedUsd,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Atomic reservation: increment only if the resulting total stays within cap.
|
|
71
|
+
// rowsAffected = 0 means a concurrent request already consumed the remaining budget.
|
|
72
|
+
const addTokens = Math.max(0, Math.ceil(estimatedTokens));
|
|
73
|
+
const addUsd = Math.max(0, estimatedUsd);
|
|
74
|
+
if (addTokens > 0 || addUsd > 0) {
|
|
75
|
+
const rowsAffected: number = isPositiveLimit(budget.monthlyTokenCap)
|
|
76
|
+
? await tx.$executeRaw`UPDATE "TokenBudget" SET "currentMonthTokens" = "currentMonthTokens" + ${addTokens}, "currentMonthUsd" = "currentMonthUsd" + ${addUsd} WHERE "tenantId" = ${tenantId} AND "currentMonthTokens" + ${addTokens} <= ${budget.monthlyTokenCap}`
|
|
77
|
+
: await tx.$executeRaw`UPDATE "TokenBudget" SET "currentMonthTokens" = "currentMonthTokens" + ${addTokens}, "currentMonthUsd" = "currentMonthUsd" + ${addUsd} WHERE "tenantId" = ${tenantId}`;
|
|
78
|
+
if (rowsAffected === 0) {
|
|
79
|
+
throw new TokenBudgetExceededError(
|
|
80
|
+
`Monthly token budget exceeded. Increase the workspace budget or wait until next month.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
/** Write one LLM call record. Call after every askTracked(). */
|
|
89
|
+
export async function recordLLMCall(params: {
|
|
90
|
+
tenantId: string;
|
|
91
|
+
projectId?: string;
|
|
92
|
+
jobId?: string;
|
|
93
|
+
agentType: string;
|
|
94
|
+
label: string;
|
|
95
|
+
taskType?: string;
|
|
96
|
+
entityId?: string;
|
|
97
|
+
entityType?: string;
|
|
98
|
+
usage: LLMUsage;
|
|
99
|
+
latencyMs?: number;
|
|
100
|
+
}) {
|
|
101
|
+
await withTenant(params.tenantId, async (tx) => {
|
|
102
|
+
const budget = await tx.tokenBudget.findUnique({ where: { tenantId: params.tenantId } });
|
|
103
|
+
const now = new Date();
|
|
104
|
+
let resetNeeded = false;
|
|
105
|
+
if (budget) {
|
|
106
|
+
resetNeeded = isNewBudgetMonth(budget.lastResetAt, now);
|
|
107
|
+
assertWithinBudget({
|
|
108
|
+
monthlyTokenCap: budget.monthlyTokenCap,
|
|
109
|
+
monthlyUsdCap: budget.monthlyUsdCap,
|
|
110
|
+
currentMonthTokens: resetNeeded ? 0 : budget.currentMonthTokens,
|
|
111
|
+
currentMonthUsd: resetNeeded ? 0 : budget.currentMonthUsd,
|
|
112
|
+
additionalTokens: params.usage.totalTokens,
|
|
113
|
+
additionalUsd: params.usage.estimatedCostUsd,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await tx.lLMCallLog.create({
|
|
118
|
+
data: {
|
|
119
|
+
tenantId: params.tenantId,
|
|
120
|
+
projectId: params.projectId ?? null,
|
|
121
|
+
jobId: params.jobId ?? null,
|
|
122
|
+
agentType: params.agentType,
|
|
123
|
+
label: params.label,
|
|
124
|
+
taskType: params.taskType ?? null,
|
|
125
|
+
entityId: params.entityId ?? null,
|
|
126
|
+
entityType: params.entityType ?? null,
|
|
127
|
+
provider: params.usage.provider,
|
|
128
|
+
model: params.usage.model,
|
|
129
|
+
promptTokens: params.usage.promptTokens,
|
|
130
|
+
completionTokens: params.usage.completionTokens,
|
|
131
|
+
cacheReadTokens: params.usage.cacheReadTokens,
|
|
132
|
+
cacheWriteTokens: params.usage.cacheWriteTokens,
|
|
133
|
+
totalTokens: params.usage.totalTokens,
|
|
134
|
+
estimatedCostUsd: params.usage.estimatedCostUsd,
|
|
135
|
+
latencyMs: params.latencyMs ?? null,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (budget) {
|
|
140
|
+
await tx.tokenBudget.update({
|
|
141
|
+
where: { tenantId: params.tenantId },
|
|
142
|
+
data: {
|
|
143
|
+
currentMonthTokens: resetNeeded ? params.usage.totalTokens : { increment: params.usage.totalTokens },
|
|
144
|
+
currentMonthUsd: resetNeeded ? params.usage.estimatedCostUsd : { increment: params.usage.estimatedCostUsd },
|
|
145
|
+
lastResetAt: resetNeeded ? now : undefined,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Summarise token usage for a project within a date range. */
|
|
153
|
+
export async function getProjectTokenSummary(tenantId: string, projectId: string, since?: Date) {
|
|
154
|
+
return withTenant(tenantId, async (tx) => {
|
|
155
|
+
const rows = since
|
|
156
|
+
? await tx.$queryRaw<any[]>`
|
|
157
|
+
SELECT
|
|
158
|
+
"agentType",
|
|
159
|
+
SUM("promptTokens")::INT AS "promptTokens",
|
|
160
|
+
SUM("completionTokens")::INT AS "completionTokens",
|
|
161
|
+
SUM("cacheReadTokens")::INT AS "cacheReadTokens",
|
|
162
|
+
SUM("totalTokens")::INT AS "totalTokens",
|
|
163
|
+
SUM("estimatedCostUsd") AS "estimatedCostUsd",
|
|
164
|
+
COUNT(*)::INT AS "callCount"
|
|
165
|
+
FROM "LLMCallLog"
|
|
166
|
+
WHERE "tenantId" = ${tenantId}
|
|
167
|
+
AND "projectId" = ${projectId}
|
|
168
|
+
AND "calledAt" >= ${since}
|
|
169
|
+
GROUP BY "agentType"
|
|
170
|
+
ORDER BY SUM("totalTokens") DESC`
|
|
171
|
+
: await tx.$queryRaw<any[]>`
|
|
172
|
+
SELECT
|
|
173
|
+
"agentType",
|
|
174
|
+
SUM("promptTokens")::INT AS "promptTokens",
|
|
175
|
+
SUM("completionTokens")::INT AS "completionTokens",
|
|
176
|
+
SUM("cacheReadTokens")::INT AS "cacheReadTokens",
|
|
177
|
+
SUM("totalTokens")::INT AS "totalTokens",
|
|
178
|
+
SUM("estimatedCostUsd") AS "estimatedCostUsd",
|
|
179
|
+
COUNT(*)::INT AS "callCount"
|
|
180
|
+
FROM "LLMCallLog"
|
|
181
|
+
WHERE "tenantId" = ${tenantId}
|
|
182
|
+
AND "projectId" = ${projectId}
|
|
183
|
+
GROUP BY "agentType"
|
|
184
|
+
ORDER BY SUM("totalTokens") DESC`;
|
|
185
|
+
return rows;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Token breakdown for one job, scoped to a specific project. */
|
|
190
|
+
export async function getJobTokenDetail(tenantId: string, projectId: string, jobId: string) {
|
|
191
|
+
return withTenant(tenantId, async (tx) => {
|
|
192
|
+
const rows = await tx.$queryRaw<any[]>`
|
|
193
|
+
SELECT
|
|
194
|
+
label,
|
|
195
|
+
"promptTokens",
|
|
196
|
+
"completionTokens",
|
|
197
|
+
"cacheReadTokens",
|
|
198
|
+
"estimatedCostUsd",
|
|
199
|
+
"latencyMs",
|
|
200
|
+
"calledAt"
|
|
201
|
+
FROM "LLMCallLog"
|
|
202
|
+
WHERE "tenantId" = ${tenantId}
|
|
203
|
+
AND "projectId" = ${projectId}
|
|
204
|
+
AND "jobId" = ${jobId}
|
|
205
|
+
ORDER BY "calledAt" ASC
|
|
206
|
+
`;
|
|
207
|
+
return rows;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
buildTraceabilityMatrix, buildAlignmentReport, linkedReqIds, matchAcceptanceCriterion,
|
|
5
|
+
traceabilityToCsv, type TraceCaseInput,
|
|
6
|
+
} from './traceability.js';
|
|
7
|
+
|
|
8
|
+
const mkCase = (over: Partial<TraceCaseInput>): TraceCaseInput => ({
|
|
9
|
+
id: 'tc1', ref: 'TC-1', title: 'Verify login', kind: 'POSITIVE', status: 'APPROVED', suiteId: 's1',
|
|
10
|
+
steps: ['Enter valid username and password', 'Click Login'], expectedResult: 'Dashboard is displayed',
|
|
11
|
+
preconditions: null, traceTo: null, traceToExtra: [], scenarioId: null, suspect: false, suspectReason: null, scripts: [], ...over,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const REQ = (reqId: string, ac: string[]) => ({
|
|
15
|
+
id: `db-${reqId}`, reqId, title: `Story ${reqId}`, priority: 'HIGH', status: 'pending', source: 'JIRA_IMPORT',
|
|
16
|
+
acceptanceCriteria: ac, externalMeta: { url: `https://jira/browse/${reqId}`, status: 'Done', labels: ['auth'] },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('linkedReqIds: primary + extras, de-duplicated, placeholders removed', () => {
|
|
20
|
+
assert.deepEqual(linkedReqIds({ traceTo: 'PROJ-1', traceToExtra: ['PROJ-2', 'PROJ-1', 'REQ-UNKNOWN'] }), ['PROJ-1', 'PROJ-2']);
|
|
21
|
+
assert.deepEqual(linkedReqIds({ traceTo: 'REQ-UNKNOWN' }), []);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('matchAcceptanceCriterion: matches relevant case, rejects unrelated', () => {
|
|
25
|
+
const cases = [mkCase({})];
|
|
26
|
+
assert.equal(matchAcceptanceCriterion('User can login with valid username and password', cases).covered, true);
|
|
27
|
+
assert.equal(matchAcceptanceCriterion('Invoice PDF can be downloaded and printed', cases).covered, false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('buildTraceabilityMatrix: per-AC coverage, multi-link, orphans, broken links, retired excluded', () => {
|
|
31
|
+
const m = buildTraceabilityMatrix({
|
|
32
|
+
requirements: [
|
|
33
|
+
REQ('PROJ-1', ['User can login with valid username and password', 'Invoice PDF can be downloaded and printed']),
|
|
34
|
+
REQ('PROJ-2', ['Dashboard is displayed after login']),
|
|
35
|
+
REQ('PROJ-3', ['Something nobody tests']),
|
|
36
|
+
],
|
|
37
|
+
testCases: [
|
|
38
|
+
mkCase({ id: 'a', ref: 'TC-A', traceTo: 'PROJ-1', traceToExtra: ['PROJ-2'], suspect: true, suspectReason: 'changed',
|
|
39
|
+
scripts: [{ language: 'playwright-ts', annotated: false, aiScore: 80, aligned: true, stepCoveragePct: 100, verifiedAt: null }] }),
|
|
40
|
+
mkCase({ id: 'b', ref: 'TC-B', traceTo: 'REQ-UNKNOWN' }),
|
|
41
|
+
mkCase({ id: 'c', ref: 'TC-C', traceTo: 'PROJ-99' }),
|
|
42
|
+
mkCase({ id: 'd', ref: 'TC-D', traceTo: 'PROJ-3', status: 'RETIRED' }),
|
|
43
|
+
],
|
|
44
|
+
scenarios: [{ id: 's1', requirementId: 'db-PROJ-1', title: 'Login happy path', status: 'approved' }],
|
|
45
|
+
});
|
|
46
|
+
const p1 = m.requirements.find((r) => r.reqId === 'PROJ-1')!;
|
|
47
|
+
assert.equal(p1.coverageState, 'partial');
|
|
48
|
+
assert.equal(p1.acCoveragePct, 50);
|
|
49
|
+
assert.equal(p1.hasSuspect, true);
|
|
50
|
+
assert.equal(p1.scenarios.length, 1);
|
|
51
|
+
assert.equal(m.requirements.find((r) => r.reqId === 'PROJ-2')!.testCases[0].linkType, 'additional');
|
|
52
|
+
assert.equal(m.requirements.find((r) => r.reqId === 'PROJ-3')!.coverageState, 'untested'); // retired test ignored
|
|
53
|
+
assert.deepEqual(m.orphans.map((o) => o.ref), ['TC-B']);
|
|
54
|
+
assert.deepEqual(m.brokenLinks.map((b) => b.reqId), ['PROJ-99']);
|
|
55
|
+
assert.equal(m.summary.scriptsMissingAnnotation, 1);
|
|
56
|
+
assert.equal(m.summary.suspectTestCases, 1);
|
|
57
|
+
assert.equal(m.summary.requirementsUntested, 1);
|
|
58
|
+
assert.match(traceabilityToCsv(m), /PROJ-1,Story PROJ-1/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('buildAlignmentReport: average, pass rate at 75, distribution, uncovered steps', () => {
|
|
62
|
+
const rep = buildAlignmentReport([
|
|
63
|
+
{ testCaseId: '1', ref: 'A', title: 'a', traceTo: 'R1', language: 'playwright-ts', score: 90, aligned: true, issues: [], stepCoveragePct: 100,
|
|
64
|
+
stepCoverage: { totalSteps: 2, coveredSteps: 2, perStep: [], assertionCoverage: { pct: 100 } }, verifiedAt: null },
|
|
65
|
+
{ testCaseId: '2', ref: 'B', title: 'b', traceTo: 'R1', language: 'playwright-ts', score: 50, aligned: false, issues: ['x'], stepCoveragePct: 50,
|
|
66
|
+
stepCoverage: { totalSteps: 2, coveredSteps: 1, perStep: [{ index: 2, step: 'Upload', covered: false, confidence: 0 }] }, verifiedAt: null },
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(rep.summary.avgAiScore, 70);
|
|
69
|
+
assert.equal(rep.summary.passRatePct, 50);
|
|
70
|
+
assert.equal(rep.summary.belowExportGate, 1);
|
|
71
|
+
assert.equal(rep.summary.avgStepCoveragePct, 75);
|
|
72
|
+
assert.equal(rep.rows[0].ref, 'B'); // lowest first
|
|
73
|
+
assert.deepEqual(rep.rows[0].uncoveredSteps, [{ index: 2, step: 'Upload' }]);
|
|
74
|
+
assert.equal(rep.summary.distribution['90-100'], 1);
|
|
75
|
+
});
|