@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.
Files changed (47) hide show
  1. package/README.md +13 -2
  2. package/dist/archetype-classification.d.ts +54 -0
  3. package/dist/archetype-classification.js +95 -0
  4. package/dist/cloud-storage.d.ts +106 -0
  5. package/dist/cloud-storage.js +539 -0
  6. package/dist/email.d.ts +21 -0
  7. package/dist/email.js +234 -0
  8. package/dist/grounding.d.ts +30 -0
  9. package/dist/grounding.js +138 -0
  10. package/dist/index.d.ts +13 -0
  11. package/dist/index.js +37 -0
  12. package/dist/nav-graph.d.ts +42 -0
  13. package/dist/nav-graph.js +248 -0
  14. package/dist/notifications.d.ts +26 -0
  15. package/dist/notifications.js +105 -0
  16. package/dist/orphan-classification.d.ts +32 -0
  17. package/dist/orphan-classification.js +40 -0
  18. package/dist/repository.d.ts +3185 -0
  19. package/dist/repository.js +5927 -0
  20. package/dist/run-notifications.d.ts +16 -0
  21. package/dist/run-notifications.js +244 -0
  22. package/dist/skill-file-kinds.d.ts +5 -0
  23. package/dist/skill-file-kinds.js +50 -0
  24. package/dist/token-ledger.d.ts +23 -0
  25. package/dist/token-ledger.js +178 -0
  26. package/dist/traceability.d.ts +213 -0
  27. package/dist/traceability.js +339 -0
  28. package/package.json +24 -4
  29. package/src/archetype-classification.test.ts +91 -0
  30. package/src/archetype-classification.ts +132 -0
  31. package/src/cloud-storage.test.ts +83 -0
  32. package/src/cloud-storage.ts +627 -0
  33. package/src/email.ts +268 -0
  34. package/src/grounding.ts +172 -0
  35. package/src/index.ts +18 -0
  36. package/src/nav-graph.ts +295 -0
  37. package/src/notifications.ts +156 -0
  38. package/src/orphan-classification.test.ts +61 -0
  39. package/src/orphan-classification.ts +65 -0
  40. package/src/orphan-screens.test.ts +88 -0
  41. package/src/repository.ts +7397 -0
  42. package/src/run-notifications.ts +281 -0
  43. package/src/skill-file-kinds.ts +51 -0
  44. package/src/token-ledger.ts +209 -0
  45. package/src/traceability.test.ts +75 -0
  46. package/src/traceability.ts +477 -0
  47. package/tsconfig.json +9 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Screen-archetype classification + KAG journey-pair suggestion — pure logic,
3
+ * no I/O, unit-testable in isolation (same shape as orphan-classification.ts).
4
+ *
5
+ * Rule-based rather than LLM-based on purpose: this only needs to look at
6
+ * data already captured during crawl (Element.role/expectedData, Screen.url/
7
+ * name), so it works with zero LLM provider configured and costs nothing to
8
+ * run on every project. It is deliberately coarse — good enough to rank
9
+ * "which screen pairs are worth generating a KAG journey test for" so a user
10
+ * doesn't have to already know both screen ids, not a general-purpose UI
11
+ * classifier.
12
+ */
13
+
14
+ export type ScreenArchetype = 'LOGIN' | 'SIGNUP' | 'SEARCH' | 'CHECKOUT' | 'FORM' | 'GENERIC';
15
+
16
+ export interface ClassifiableElement {
17
+ role: string | null;
18
+ expectedData: string | null;
19
+ }
20
+
21
+ export interface ClassifiableScreen {
22
+ id: string;
23
+ name: string;
24
+ url: string | null;
25
+ }
26
+
27
+ export interface ArchetypeResult {
28
+ archetype: ScreenArchetype;
29
+ reason: string;
30
+ }
31
+
32
+ const LOGIN_URL_RE = /login|signin|sign-in/i;
33
+ const SIGNUP_URL_RE = /signup|sign-up|register|onboard/i;
34
+ const CHECKOUT_URL_RE = /checkout|cart|basket/i;
35
+ // Element.role values observed at crawl time (crawler.ts) are lowercase HTML
36
+ // input types or tag/aria names — see that file for exactly how they're set.
37
+ const FORM_FIELD_ROLES = new Set(['text', 'email', 'tel', 'number', 'date', 'textarea', 'select']);
38
+
39
+ /** Classifies a single screen from its URL/name and the elements crawled on it.
40
+ * Checked in priority order — the first matching, most specific signal wins,
41
+ * since e.g. a checkout page often also has a form field or two. */
42
+ export function classifyScreenArchetype(
43
+ screen: ClassifiableScreen,
44
+ elements: ClassifiableElement[],
45
+ ): ArchetypeResult {
46
+ const hasPassword = elements.some((e) => e.role === 'password' || e.expectedData === 'password');
47
+ const hasEmail = elements.some((e) => e.role === 'email' || e.expectedData === 'valid_email');
48
+ const hasSearch = elements.some((e) => e.role === 'search' || e.expectedData === 'search_term');
49
+ const urlOrName = `${screen.url ?? ''} ${screen.name ?? ''}`;
50
+
51
+ if (CHECKOUT_URL_RE.test(urlOrName)) {
52
+ return { archetype: 'CHECKOUT', reason: "URL/name matches checkout/cart/basket" };
53
+ }
54
+ if (SIGNUP_URL_RE.test(urlOrName) && hasEmail && hasPassword) {
55
+ return { archetype: 'SIGNUP', reason: 'URL/name matches signup/register, with email + password fields present' };
56
+ }
57
+ if (hasPassword) {
58
+ return { archetype: 'LOGIN', reason: 'Has a password-type input element' };
59
+ }
60
+ if (LOGIN_URL_RE.test(urlOrName)) {
61
+ return { archetype: 'LOGIN', reason: 'URL/name matches login/signin' };
62
+ }
63
+ if (hasSearch) {
64
+ return { archetype: 'SEARCH', reason: 'Has a search-type input element' };
65
+ }
66
+ if (elements.some((e) => e.role && FORM_FIELD_ROLES.has(e.role))) {
67
+ return { archetype: 'FORM', reason: 'Has one or more data-entry form fields' };
68
+ }
69
+ return { archetype: 'GENERIC', reason: 'No distinguishing signals found' };
70
+ }
71
+
72
+ export interface ClassifiedScreen {
73
+ id: string;
74
+ name: string;
75
+ archetype: ScreenArchetype;
76
+ }
77
+
78
+ export interface JourneyCandidate {
79
+ startScreenId: string;
80
+ startScreenName: string;
81
+ startArchetype: ScreenArchetype;
82
+ endScreenId: string;
83
+ endScreenName: string;
84
+ endArchetype: ScreenArchetype;
85
+ label: string;
86
+ }
87
+
88
+ /** Archetype pairs worth generating a cross-screen journey test for, ranked
89
+ * by how commonly they represent a real user flow. Order matters — earlier
90
+ * entries are suggested before later ones when a project has many candidate
91
+ * pairs. GENERIC is deliberately excluded from both sides: with no
92
+ * distinguishing signal, pairing it with anything is a guess, not a
93
+ * suggestion. */
94
+ const INTERESTING_JOURNEYS: Array<{ from: ScreenArchetype; to: ScreenArchetype; label: string }> = [
95
+ { from: 'LOGIN', to: 'CHECKOUT', label: 'Login → Checkout' },
96
+ { from: 'SIGNUP', to: 'LOGIN', label: 'Signup → Login' },
97
+ { from: 'SEARCH', to: 'CHECKOUT', label: 'Search → Checkout' },
98
+ { from: 'LOGIN', to: 'FORM', label: 'Login → Form submission' },
99
+ { from: 'SEARCH', to: 'FORM', label: 'Search → Form submission' },
100
+ ];
101
+
102
+ /**
103
+ * Finds every (start, end) screen pair whose archetypes match one of the
104
+ * interesting-journey rules. Pure and graph-agnostic on purpose — this does
105
+ * NOT check whether a KAG path actually connects the two screens (that
106
+ * requires a DB-backed BFS, see AppBrain.suggestJourneys in repository.ts,
107
+ * which calls this first to get a small, ranked candidate list and only then
108
+ * pays for a graph traversal per candidate instead of checking every
109
+ * archetype pair against every other screen).
110
+ */
111
+ export function matchInterestingJourneyPairs(screens: ClassifiedScreen[]): JourneyCandidate[] {
112
+ const candidates: JourneyCandidate[] = [];
113
+ for (const rule of INTERESTING_JOURNEYS) {
114
+ const starts = screens.filter((s) => s.archetype === rule.from);
115
+ const ends = screens.filter((s) => s.archetype === rule.to);
116
+ for (const start of starts) {
117
+ for (const end of ends) {
118
+ if (start.id === end.id) continue;
119
+ candidates.push({
120
+ startScreenId: start.id,
121
+ startScreenName: start.name,
122
+ startArchetype: start.archetype,
123
+ endScreenId: end.id,
124
+ endScreenName: end.name,
125
+ endArchetype: end.archetype,
126
+ label: rule.label,
127
+ });
128
+ }
129
+ }
130
+ }
131
+ return candidates;
132
+ }
@@ -0,0 +1,83 @@
1
+ import crypto from 'node:crypto';
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { invalidateSystemConfigCache } from '@taodigital/core';
5
+ import { prisma, prismaAdmin } from '@taodigital/database';
6
+ import { getTenantStorageConfig } from './cloud-storage.js';
7
+
8
+ // Run without AWS_S3_BUCKET/etc set (see packages/core's test:platform-storage script
9
+ // for the platform-fallback-is-active case) — so getTenantStorageConfig()'s fallback
10
+ // to a platform default is expected to resolve to null here, exercising the
11
+ // "nothing configured at all" path deliberately.
12
+
13
+ function id(prefix: string) {
14
+ return `${prefix}_${crypto.randomUUID()}`;
15
+ }
16
+
17
+ async function makeTenant() {
18
+ const tenantId = id('tenant');
19
+ await prisma.$transaction(async (tx) => {
20
+ await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId);
21
+ await tx.tenant.create({ data: { id: tenantId, name: 'Cloud Storage Test Tenant', slug: tenantId, isolation: 'SHARED', region: 'US' } });
22
+ });
23
+ return tenantId;
24
+ }
25
+
26
+ test('getTenantStorageConfig: returns null when tenant has no connector and no platform default is configured', async () => {
27
+ const rows = await prismaAdmin.systemConfig.findMany({ where: { category: 'storage' } });
28
+ await prismaAdmin.systemConfig.deleteMany({ where: { category: 'storage' } });
29
+ await prismaAdmin.systemConfig.createMany({
30
+ data: [
31
+ { key: 'storage.provider', category: 'storage', value: '', sensitive: false },
32
+ { key: 'storage.bucket', category: 'storage', value: '', sensitive: false },
33
+ ],
34
+ });
35
+ invalidateSystemConfigCache('storage');
36
+ try {
37
+ const tenantId = await makeTenant();
38
+ const cfg = await getTenantStorageConfig(tenantId);
39
+ assert.equal(cfg, null);
40
+ } finally {
41
+ await prismaAdmin.systemConfig.deleteMany({ where: { category: 'storage' } });
42
+ if (rows.length) {
43
+ await prismaAdmin.systemConfig.createMany({
44
+ data: rows.map(({ key, value, category, sensitive, updatedByAdminId }) => ({
45
+ key,
46
+ value,
47
+ category,
48
+ sensitive,
49
+ updatedByAdminId,
50
+ })),
51
+ });
52
+ }
53
+ invalidateSystemConfigCache('storage');
54
+ }
55
+ });
56
+
57
+ test('getTenantStorageConfig: returns the tenant-specific connector when one is configured', async () => {
58
+ const tenantId = await makeTenant();
59
+ const { encrypt } = await import('@taodigital/core');
60
+ await prisma.$transaction(async (tx) => {
61
+ await tx.$executeRawUnsafe(`SELECT set_config('app.current_tenant', $1, true)`, tenantId);
62
+ await tx.tenantSetting.create({
63
+ data: {
64
+ id: id('tsetting'),
65
+ tenantId,
66
+ category: 'STORAGE',
67
+ config: {
68
+ provider: 'S3',
69
+ bucket: 'tenant-own-bucket',
70
+ region: 'eu-west-1',
71
+ accessKeyId: 'AKIATENANT',
72
+ secretAccessKey_enc: encrypt('tenant-secret'),
73
+ },
74
+ },
75
+ });
76
+ });
77
+
78
+ const cfg = await getTenantStorageConfig(tenantId);
79
+ assert.ok(cfg);
80
+ assert.equal(cfg!.bucket, 'tenant-own-bucket');
81
+ assert.equal(cfg!.region, 'eu-west-1');
82
+ assert.equal(cfg!.secretAccessKey, 'tenant-secret');
83
+ });