@arcadialdev/arcality 3.0.2 → 3.0.4

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.
@@ -1,10 +1,10 @@
1
1
  // src/arcalityClient.mjs
2
2
  // Client for communicating with Arcality Backend
3
- // Supports arcality.config (primary) and .env (fallback)
4
- // Includes MOCK MODE for development without backend
5
- import fs from 'node:fs';
6
- import path from 'node:path';
7
- import { getApiKey, getApiUrl, CONFIG_DIR } from './configLoader.mjs';
3
+ // Supports arcality.config (primary) and .env (fallback)
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { getApiKey, getApiUrl, CONFIG_DIR } from './configLoader.mjs';
7
+ import { isRealProjectId } from './configManager.mjs';
8
8
 
9
9
  // ── Backend config (internal, not user-configurable) ──
10
10
  const DAILY_MISSION_LIMIT = 35;
@@ -97,11 +97,11 @@ function getEffectiveApiKey() {
97
97
  * Gets the effective Project ID.
98
98
  * Priority: arcality.config > ARCALITY_PROJECT_ID env var
99
99
  */
100
- function loadProjectId() {
101
- const localConfig = loadArcalityConfig();
102
- if (localConfig?.projectId) return localConfig.projectId;
103
- return process.env.ARCALITY_PROJECT_ID || null;
104
- }
100
+ function loadProjectId() {
101
+ const localConfig = loadArcalityConfig();
102
+ const projectId = localConfig?.projectId || process.env.ARCALITY_PROJECT_ID || null;
103
+ return isRealProjectId(projectId) ? projectId : null;
104
+ }
105
105
 
106
106
  // ═══════════════════════════════════════════════════════
107
107
  // PUBLIC API
@@ -126,11 +126,11 @@ export async function validateApiKey() {
126
126
  const key = getEffectiveApiKey();
127
127
 
128
128
  if (!key) {
129
- return { valid: false, error: 'no_api_key', mode: 'mock' };
129
+ return { valid: false, error: 'no_api_key', mode: 'live' };
130
130
  }
131
131
 
132
132
  if (!key.startsWith('arc_')) {
133
- return { valid: false, error: 'invalid_format', mode: 'mock' };
133
+ return { valid: false, error: 'invalid_format', mode: 'live' };
134
134
  }
135
135
 
136
136
  const apiBase = getEffectiveApiBase();
@@ -162,20 +162,12 @@ export async function validateApiKey() {
162
162
  return { ...data, valid: true, mode: 'live' };
163
163
  } catch (e) {
164
164
  const reason = e?.name === 'AbortError' ? 'timeout (5s)' : (e?.message || 'unknown');
165
- console.warn(`⚠️ Backend not available (${apiBase}): ${reason}. Using local mode.`);
165
+ console.warn(`Backend not available (${apiBase}): ${reason}.`);
166
+ return { valid: false, error: 'backend_unavailable', mode: 'live', reason };
166
167
  }
167
168
  }
168
169
 
169
- // ── MOCK mode: No backend ──
170
- const dailyUsed = getLocalDailyUsage();
171
- return {
172
- valid: true,
173
- mode: 'mock',
174
- plan: 'internal',
175
- daily_used: dailyUsed,
176
- daily_limit: DAILY_MISSION_LIMIT,
177
- remaining: Math.max(0, DAILY_MISSION_LIMIT - dailyUsed)
178
- };
170
+ return { valid: false, error: 'backend_unavailable', mode: 'live' };
179
171
  }
180
172
 
181
173
  /**
@@ -192,7 +184,10 @@ export async function startMission(prompt, targetUrl) {
192
184
  // ── LIVE mode ──
193
185
  if (apiBase) {
194
186
  try {
195
- const projectId = process.env.ARCALITY_PROJECT_ID || loadArcalityConfig()?.projectId;
187
+ const projectId = process.env.ARCALITY_PROJECT_ID || loadArcalityConfig()?.projectId;
188
+ if (!isRealProjectId(projectId)) {
189
+ return { allowed: false, error: 'no_project_id' };
190
+ }
196
191
 
197
192
  const controller = new AbortController();
198
193
  const timeout = setTimeout(() => controller.abort(), 5000);
@@ -221,38 +216,13 @@ export async function startMission(prompt, targetUrl) {
221
216
  return { allowed: true, ...(await res.json()) };
222
217
  } catch (e) {
223
218
  const reason = e?.name === 'AbortError' ? 'timeout (5s)' : (e?.message || 'unknown');
224
- console.warn(`⚠️ startMission failed (${apiBase}): ${reason}. Falling back to mock mode.`);
219
+ console.warn(`startMission failed (${apiBase}): ${reason}.`);
220
+ return { allowed: false, error: 'backend_unavailable', reason };
225
221
  }
226
222
  }
227
223
 
228
- // ── MOCK mode ──
229
- const dailyUsed = getLocalDailyUsage();
230
-
231
- if (dailyUsed >= DAILY_MISSION_LIMIT) {
232
- const tomorrow = new Date();
233
- tomorrow.setDate(tomorrow.getDate() + 1);
234
- tomorrow.setHours(0, 0, 0, 0);
235
-
236
- return {
237
- allowed: false,
238
- error: 'mission_limit_exceeded',
239
- daily_used: dailyUsed,
240
- daily_limit: DAILY_MISSION_LIMIT,
241
- remaining: 0,
242
- resets_at: tomorrow.toISOString()
243
- };
244
- }
245
-
246
- const newCount = incrementLocalUsage();
247
- return {
248
- allowed: true,
249
- mode: 'mock',
250
- mission_id: `mock_${Date.now().toString(36)}`,
251
- daily_used: newCount,
252
- daily_limit: DAILY_MISSION_LIMIT,
253
- remaining: DAILY_MISSION_LIMIT - newCount
254
- };
255
- }
224
+ return { allowed: false, error: 'backend_unavailable' };
225
+ }
256
226
 
257
227
  /**
258
228
  * Fetches missions from the backend API.
@@ -264,11 +234,10 @@ export async function fetchMissions(projectId, tags) {
264
234
  const key = getEffectiveApiKey();
265
235
  if (!key) return { success: false, error: 'no_api_key', missions: [] };
266
236
 
267
- const apiBase = getEffectiveApiBase();
268
- if (!apiBase) {
269
- // MOCK MODE: Return empty array for now since there's no backend
270
- return { success: true, mode: 'mock', missions: [] };
271
- }
237
+ const apiBase = getEffectiveApiBase();
238
+ if (!apiBase) {
239
+ return { success: false, error: 'backend_unavailable', missions: [] };
240
+ }
272
241
 
273
242
  try {
274
243
  const url = new URL(`${apiBase}/api/v1/projects/${projectId}/missions`);
@@ -308,9 +277,10 @@ export async function fetchMissions(projectId, tags) {
308
277
  * @param {'success'|'failed'|'cancelled'} result
309
278
  * @param {object} usage - Optional usage statistics directly from the AI
310
279
  */
311
- export async function endMission(missionId, result, usage = null, failReason = null, reportUrl = null, failReasoning = null) {
312
- const apiBase = getEffectiveApiBase();
313
- if (!apiBase || !missionId) return { ok: true };
280
+ export async function endMission(missionId, result, usage = null, failReason = null, reportUrl = null, failReasoning = null) {
281
+ const apiBase = getEffectiveApiBase();
282
+ if (!apiBase) return { ok: false, error: 'backend_unavailable' };
283
+ if (!missionId) return { ok: false, error: 'no_mission_id' };
314
284
 
315
285
  const key = getEffectiveApiKey();
316
286
  try {
@@ -336,10 +306,16 @@ function simpleHash(str) {
336
306
  hash = ((hash << 5) - hash) + char;
337
307
  hash |= 0;
338
308
  }
339
- return 'ph_' + Math.abs(hash).toString(36);
340
- }
341
-
342
- /**
309
+ return 'ph_' + Math.abs(hash).toString(36);
310
+ }
311
+
312
+ function normalizeBugClassification(value) {
313
+ const text = String(value || '').trim();
314
+ const allowed = ['[Backend Bug]', '[Frontend Bug]', '[UI Regression]', '[Flaky/Stale Selector]'];
315
+ return allowed.find(category => text.includes(category)) || '';
316
+ }
317
+
318
+ /**
343
319
  * Crea una Task en Azure DevOps cuando se detecta un bug en el portal.
344
320
  * Si la organización no tiene la integración configurada (HTTP 400) se registra
345
321
  * sólo en modo DEBUG y NO interrumpe el flujo del test runner.
@@ -348,7 +324,7 @@ function simpleHash(str) {
348
324
  * @param {string} description - Descripción detallada del bug detectado
349
325
  * @returns {Promise<{success: boolean, taskUrl?: string, error?: string}>}
350
326
  */
351
- export async function createAdoTask(title, description) {
327
+ export async function createAdoTask(title, description, metadata = {}) {
352
328
  const apiBase = getEffectiveApiBase();
353
329
  if (!apiBase) {
354
330
  if (process.env.DEBUG) console.log(`[ADO] No hay ARCALITY_API_URL configurado. Omitiendo creación de Task en Azure DevOps.`);
@@ -356,7 +332,7 @@ export async function createAdoTask(title, description) {
356
332
  }
357
333
 
358
334
  const projectId = loadProjectId();
359
- if (!projectId) {
335
+ if (!isRealProjectId(projectId)) {
360
336
  if (process.env.DEBUG) console.log(`[ADO] No hay Project ID configurado. Omitiendo creación de Task en Azure DevOps.`);
361
337
  return { success: false, error: 'no_project_id' };
362
338
  }
@@ -367,19 +343,26 @@ export async function createAdoTask(title, description) {
367
343
  return { success: false, error: 'no_api_key' };
368
344
  }
369
345
 
370
- try {
371
- const controller = new AbortController();
372
- const timeout = setTimeout(() => controller.abort(), 15000);
373
-
374
- const res = await fetch(`${apiBase}/api/v1/integrations/azuredevops/task`, {
375
- method: 'POST',
376
- headers: {
377
- 'Content-Type': 'application/json',
378
- 'x-api-key': key
379
- },
380
- body: JSON.stringify({ project_id: projectId, title, description }),
381
- signal: controller.signal
382
- });
346
+ try {
347
+ const controller = new AbortController();
348
+ const timeout = setTimeout(() => controller.abort(), 15000);
349
+ const bugClassification = normalizeBugClassification(metadata?.classification);
350
+ const taskTitle = bugClassification && !String(title).includes(bugClassification)
351
+ ? `${bugClassification} ${title}`
352
+ : title;
353
+ const taskDescription = bugClassification && !String(description).includes('Clasificacion')
354
+ ? `**Clasificacion:** ${bugClassification}\n\n${description}`
355
+ : description;
356
+
357
+ const res = await fetch(`${apiBase}/api/v1/integrations/azuredevops/task`, {
358
+ method: 'POST',
359
+ headers: {
360
+ 'Content-Type': 'application/json',
361
+ 'x-api-key': key
362
+ },
363
+ body: JSON.stringify({ project_id: projectId, title: taskTitle, description: taskDescription }),
364
+ signal: controller.signal
365
+ });
383
366
  clearTimeout(timeout);
384
367
 
385
368
  if (res.status === 400) {
@@ -415,7 +398,7 @@ export async function createAdoTask(title, description) {
415
398
  */
416
399
  export async function getEvidenceSasToken(missionId, projectId) {
417
400
  const apiBase = getEffectiveApiBase();
418
- if (!apiBase || !missionId) return null;
401
+ if (!apiBase || !missionId || !isRealProjectId(projectId)) return null;
419
402
 
420
403
  const key = getEffectiveApiKey();
421
404
  if (!key) return null;
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import dotenv from 'dotenv';
7
+ import { isRealProjectId } from './configManager.mjs';
7
8
 
8
9
  dotenv.config();
9
10
 
@@ -62,12 +63,17 @@ export function loadConfig() {
62
63
  throw new Error('ARCALITY_API_KEY is not defined. Run `arcality init` or set it in your .env file.');
63
64
  }
64
65
 
66
+ if (!isRealProjectId(config.ARCALITY_PROJECT_ID)) {
67
+ throw new Error('ARCALITY_PROJECT_ID is not connected to a real Arcality project. Run `arcality init`.');
68
+ }
69
+
65
70
  return config;
66
71
  }
67
72
 
68
73
  export function loadProjectId() {
69
74
  const localConfig = loadLocalArcalityConfig();
70
- return localConfig?.projectId || process.env.ARCALITY_PROJECT_ID;
75
+ const projectId = localConfig?.projectId || process.env.ARCALITY_PROJECT_ID;
76
+ return isRealProjectId(projectId) ? projectId : null;
71
77
  }
72
78
 
73
79
 
@@ -128,7 +134,7 @@ export function getApiKey() {
128
134
  }
129
135
 
130
136
  /**
131
- * Loads additional secrets (like Anthropic) from global config
137
+ * Loads supported Arcality runtime values into process.env.
132
138
  */
133
139
  export function setupProcessEnv() {
134
140
  const config = loadGlobalConfig();
@@ -137,23 +143,13 @@ export function setupProcessEnv() {
137
143
  // Load Arcality API Key
138
144
  getApiKey();
139
145
 
140
- // Load Anthropic API Key if not already in env
141
- if (config.anthropic_api_key && !process.env.ANTHROPIC_API_KEY) {
142
- process.env.ANTHROPIC_API_KEY = config.anthropic_api_key;
143
- }
144
-
145
- // Load default model if exists
146
- if (config.model && !process.env.CLAUDE_MODEL) {
147
- process.env.CLAUDE_MODEL = config.model;
148
- }
149
-
150
146
  // Also inject arcality.config values into process.env for backward compat
151
147
  const localConfig = loadLocalArcalityConfig();
152
148
  if (localConfig) {
153
149
  if (localConfig.apiKey && !process.env.ARCALITY_API_KEY) {
154
150
  process.env.ARCALITY_API_KEY = localConfig.apiKey;
155
151
  }
156
- if (localConfig.projectId && !process.env.ARCALITY_PROJECT_ID) {
152
+ if (isRealProjectId(localConfig.projectId) && !process.env.ARCALITY_PROJECT_ID) {
157
153
  process.env.ARCALITY_PROJECT_ID = localConfig.projectId;
158
154
  }
159
155
  if (localConfig.project?.baseUrl && !process.env.BASE_URL) {
@@ -5,7 +5,16 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- const CONFIG_FILENAME = 'arcality.config';
8
+ const CONFIG_FILENAME = 'arcality.config';
9
+
10
+ export function isRealProjectId(projectId) {
11
+ const id = String(projectId || '').trim();
12
+ if (!id) return false;
13
+ if (id === 'undefined' || id === 'null') return false;
14
+ if (/^(local|mock)_/i.test(id)) return false;
15
+ if (/^0{8}-0{4}-0{4}-0{4}-0{12}$/i.test(id)) return false;
16
+ return true;
17
+ }
9
18
 
10
19
  /**
11
20
  * Resolves the path to arcality.config in the project root.
@@ -59,21 +68,21 @@ export function saveProjectConfig(config, projectRoot) {
59
68
  * @param {ArcalityConfig} config
60
69
  * @returns {{ valid: boolean, missing: string[] }}
61
70
  */
62
- export function validateConfig(config) {
63
- const missing = [];
71
+ export function validateConfig(config) {
72
+ const missing = [];
64
73
 
65
74
  if (!config) {
66
75
  return { valid: false, missing: ['arcality.config file'] };
67
76
  }
68
77
 
69
- if (!config.apiKey) missing.push('apiKey');
70
- if (!config.projectId) missing.push('projectId');
71
- if (!config.project?.baseUrl) missing.push('project.baseUrl');
72
- if (!config.auth?.username) missing.push('auth.username');
73
- if (!config.auth?.password) missing.push('auth.password');
74
-
75
- return { valid: missing.length === 0, missing };
76
- }
78
+ if (!config.apiKey) missing.push('apiKey');
79
+ if (!isRealProjectId(config.projectId)) missing.push('projectId');
80
+ if (!config.project?.baseUrl) missing.push('project.baseUrl');
81
+ if (!config.auth?.username) missing.push('auth.username');
82
+ if (!config.auth?.password) missing.push('auth.password');
83
+
84
+ return { valid: missing.length === 0, missing };
85
+ }
77
86
 
78
87
  /**
79
88
  * Creates a fresh arcality.config structure.
@@ -84,30 +93,30 @@ export function createConfig({
84
93
  apiKey,
85
94
  organizationId,
86
95
  projectId,
87
- projectName,
88
- baseUrl,
89
- frameworkDetected,
90
- username,
91
- password,
92
- arcalityVersion,
93
- yamlOutputDir = './arcality',
94
- }) {
95
- return {
96
+ projectName,
97
+ baseUrl,
98
+ frameworkDetected,
99
+ username,
100
+ password,
101
+ arcalityVersion,
102
+ yamlOutputDir = './arcality',
103
+ }) {
104
+ return {
96
105
  version: '1',
97
106
  apiKey,
98
107
  organizationId: organizationId || null,
99
108
  projectId,
100
109
  project: {
101
110
  name: projectName,
102
- baseUrl,
103
- frameworkDetected: frameworkDetected || null,
104
- },
105
- auth: {
106
- username,
107
- password,
108
- },
109
- runtime: {
110
- yamlOutputDir,
111
+ baseUrl,
112
+ frameworkDetected: frameworkDetected || null,
113
+ },
114
+ auth: {
115
+ username,
116
+ password,
117
+ },
118
+ runtime: {
119
+ yamlOutputDir,
111
120
  reuseSuccessfulYamls: true,
112
121
  singleConfigurationMode: true,
113
122
  },
@@ -122,17 +131,17 @@ export function createConfig({
122
131
  * with existing modules that read from process.env.
123
132
  * @param {ArcalityConfig} config
124
133
  */
125
- export function injectConfigToEnv(config) {
126
- if (!config) return;
134
+ export function injectConfigToEnv(config) {
135
+ if (!config) return;
127
136
 
128
137
  if (config.apiKey) process.env.ARCALITY_API_KEY = config.apiKey;
129
- if (config.projectId) process.env.ARCALITY_PROJECT_ID = config.projectId;
130
- if (config.project?.baseUrl && !process.env.BASE_URL) {
131
- process.env.BASE_URL = config.project.baseUrl;
132
- }
133
- if (config.auth?.username) process.env.LOGIN_USER = config.auth.username;
134
- if (config.auth?.password) process.env.LOGIN_PASSWORD = config.auth.password;
135
- }
138
+ if (isRealProjectId(config.projectId)) process.env.ARCALITY_PROJECT_ID = config.projectId;
139
+ if (config.project?.baseUrl && !process.env.BASE_URL) {
140
+ process.env.BASE_URL = config.project.baseUrl;
141
+ }
142
+ if (config.auth?.username) process.env.LOGIN_USER = config.auth.username;
143
+ if (config.auth?.password) process.env.LOGIN_PASSWORD = config.auth.password;
144
+ }
136
145
 
137
146
  /**
138
147
  * Gets the YAML output directory from config, falling back to default.
@@ -166,7 +175,7 @@ export function ensureYamlOutputDir(config, projectRoot) {
166
175
  * @property {string|null} organizationId
167
176
  * @property {string} projectId
168
177
  * @property {{ name: string, baseUrl: string, frameworkDetected: string|null }} project
169
- * @property {{ username: string, password: string }} auth
178
+ * @property {{ username: string, password: string }} auth
170
179
  * @property {{ yamlOutputDir: string, reuseSuccessfulYamls: boolean, singleConfigurationMode: boolean }} runtime
171
180
  * @property {{ arcalityVersion: string }} meta
172
181
  */
@@ -0,0 +1,27 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export const PLAYWRIGHT_ASSETS = ['chromium', 'ffmpeg'];
4
+
5
+ export function getPlaywrightInstallCommand() {
6
+ return `npx playwright install ${PLAYWRIGHT_ASSETS.join(' ')}`;
7
+ }
8
+
9
+ export function installPlaywrightAssets({ cwd, stdio = 'inherit' } = {}) {
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn('npx', ['playwright', 'install', ...PLAYWRIGHT_ASSETS], {
12
+ stdio,
13
+ shell: true,
14
+ cwd,
15
+ });
16
+
17
+ child.on('exit', code => {
18
+ if (code === 0) {
19
+ resolve();
20
+ return;
21
+ }
22
+
23
+ reject(new Error(`Playwright install failed with exit code ${code}`));
24
+ });
25
+ child.on('error', reject);
26
+ });
27
+ }
@@ -16,25 +16,32 @@ const getPid = () => process.env.ARCALITY_PROJECT_ID || '';
16
16
  const getOrgId = () => process.env.ARCALITY_ORG_ID;
17
17
  const getMissionId = () => process.env.ARCALITY_MISSION_ID || EMPTY_GUID;
18
18
 
19
- const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
20
-
21
- let configWarningLogged = false;
22
-
23
- function isConfigured(): boolean {
19
+ const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
20
+
21
+ let configWarningLogged = false;
22
+
23
+ function hasRealProjectId(pid: string): boolean {
24
+ if (!pid || pid === EMPTY_GUID) return false;
25
+ if (/^(local|mock)_/i.test(pid)) return false;
26
+ if (pid === 'undefined' || pid === 'null') return false;
27
+ return true;
28
+ }
29
+
30
+ function isConfigured(): boolean {
24
31
  const base = getBase();
25
32
  const key = getKey();
26
33
  const pid = getPid();
27
34
  const orgId = getOrgId();
28
35
 
29
- if (!base || !key || !pid || pid === EMPTY_GUID || !orgId) {
30
- if (!configWarningLogged) {
31
- const missing = [];
32
- if (!base) missing.push('ARCALITY_API_URL');
33
- if (!key) missing.push('ARCALITY_API_KEY');
34
- if (!pid || pid === EMPTY_GUID) missing.push('ARCALITY_PROJECT_ID');
36
+ if (!base || !key || !hasRealProjectId(pid) || !orgId) {
37
+ if (!configWarningLogged) {
38
+ const missing = [];
39
+ if (!base) missing.push('ARCALITY_API_URL');
40
+ if (!key) missing.push('ARCALITY_API_KEY');
41
+ if (!hasRealProjectId(pid)) missing.push('ARCALITY_PROJECT_ID');
35
42
  if (!orgId) missing.push('ARCALITY_ORG_ID');
36
43
 
37
- console.warn(`[CollectiveMemory] Service disabled. Missing: ${missing.join(', ')}`);
44
+ if (process.env.DEBUG) console.warn(`[CollectiveMemory] Service disabled. Missing: ${missing.join(', ')}`);
38
45
  configWarningLogged = true;
39
46
  }
40
47
  return false;
@@ -191,8 +198,10 @@ export async function searchPromptPattern(prompt: string, limit: number = 5): Pr
191
198
  limit: limit
192
199
  };
193
200
 
194
- console.log(`[PatternSearch] 🔍 Buscando patrones en: ${apiUrl}`);
195
- console.log(`[PatternSearch] 📦 Payload: ${JSON.stringify(payload, null, 2)}`);
201
+ const debugPatternSearch = process.env.DEBUG === 'true' || process.env.ARCALITY_DEBUG === 'true';
202
+ if (debugPatternSearch) {
203
+ console.log(`[PatternSearch] POST ${apiUrl} | prompt_chars=${prompt.length} | limit=${limit}`);
204
+ }
196
205
 
197
206
  const res = await fetch(apiUrl, {
198
207
  method: 'POST',
@@ -204,18 +213,20 @@ export async function searchPromptPattern(prompt: string, limit: number = 5): Pr
204
213
  });
205
214
 
206
215
  if (!res.ok) {
207
- console.warn(`[PatternSearch] HTTP ${res.status} from ${apiUrl}`);
208
- return [];
209
- }
210
- const data = await res.json();
211
- const results = Array.isArray(data) ? data : (data.matches || data.results || []);
212
- console.log(`[PatternSearch] ${results.length} patrones encontrados.`);
213
- return results;
214
- } catch (err: any) {
215
- console.warn(`[PatternSearch] ⚠️ Error buscando patrones: ${err?.message || 'unknown'}`);
216
- return [];
217
- }
218
- }
216
+ if (debugPatternSearch) console.warn(`[PatternSearch] HTTP ${res.status} from ${apiUrl}`);
217
+ return [];
218
+ }
219
+ const data = await res.json();
220
+ const results = Array.isArray(data) ? data : (data.matches || data.results || []);
221
+ if (debugPatternSearch) console.log(`[PatternSearch] ${results.length} patrones encontrados.`);
222
+ return results;
223
+ } catch (err: any) {
224
+ if (process.env.DEBUG === 'true' || process.env.ARCALITY_DEBUG === 'true') {
225
+ console.warn(`[PatternSearch] Error buscando patrones: ${err?.message || 'unknown'}`);
226
+ }
227
+ return [];
228
+ }
229
+ }
219
230
 
220
231
  export async function savePromptPattern(patternData: any): Promise<boolean> {
221
232
  if (!isConfigured()) return false;
@@ -46,13 +46,16 @@ class ArcalityReporter {
46
46
  fs_1.default.mkdirSync(attachmentsDir, { recursive: true });
47
47
  }
48
48
  try {
49
+ let copied = false;
49
50
  if (att.path && fs_1.default.existsSync(att.path)) {
50
51
  fs_1.default.copyFileSync(att.path, destPath);
52
+ copied = true;
51
53
  }
52
54
  else if (att.body) {
53
55
  fs_1.default.writeFileSync(destPath, att.body);
56
+ copied = true;
54
57
  }
55
- return { ...att, path: `attachments/${fileName}` };
58
+ return copied ? { ...att, path: `attachments/${fileName}` } : att;
56
59
  }
57
60
  catch (e) {
58
61
  console.error(`Failed to process attachment ${att.name}:`, e);
@@ -744,6 +747,11 @@ class ArcalityReporter {
744
747
  background: var(--brand-soft);
745
748
  border-color: rgba(181,108,255,0.26);
746
749
  }
750
+ .tag-bug {
751
+ color: #b7e4ff;
752
+ background: rgba(80,170,255,0.12);
753
+ border-color: rgba(80,170,255,0.3);
754
+ }
747
755
  .tag-retry {
748
756
  color: #ffe2a6;
749
757
  background: var(--warning-soft);
@@ -938,6 +946,8 @@ class ArcalityReporter {
938
946
  const isOpen = this.results.length === 1 || tone === 'failed' || i === 0;
939
947
  const successSummary = r.attachments.find(a => a.name === 'success_summary');
940
948
  const successText = this.getAttachmentText(successSummary);
949
+ const bugClassificationAtt = r.attachments.find(a => a.name === 'bug_classification');
950
+ const bugClassification = (this.getAttachmentText(bugClassificationAtt) || '').trim();
941
951
  const evidenceHtml = this.renderVisualEvidence(r.attachments);
942
952
  const contextHtml = this.renderTextAttachmentSection(r.attachments, 'qa_context_summary', 'Contexto QA aplicado');
943
953
  const securityHtml = this._renderSecuritySection(r.attachments, i);
@@ -951,6 +961,7 @@ class ArcalityReporter {
951
961
  <div class="test-meta">
952
962
  <span class="tag tag-status-${tone}">${this.getStatusLabel(r.result.status)}</span>
953
963
  <span class="tag tag-project">${this.escapeHtml(projectName)}</span>
964
+ ${bugClassification ? `<span class="tag tag-bug">${this.escapeHtml(bugClassification)}</span>` : ''}
954
965
  ${r.result.retry > 0 ? `<span class="tag tag-retry">Retry #${r.result.retry}</span>` : ''}
955
966
  <span class="tag">${this.formatDuration(r.result.duration)}</span>
956
967
  </div>
@@ -969,6 +980,13 @@ class ArcalityReporter {
969
980
  </div>
970
981
  ` : ''}
971
982
 
983
+ ${r.result.error && bugClassification ? `
984
+ <div class="outcome-block error">
985
+ <span class="outcome-label">Clasificacion AI</span>
986
+ <pre class="error-block">${this.escapeHtml(bugClassification)}</pre>
987
+ </div>
988
+ ` : ''}
989
+
972
990
  ${r.result.error ? `
973
991
  <div class="outcome-block error">
974
992
  <span class="outcome-label">Diagnostico del bloqueo</span>