copilot-usage-studio 0.1.0 → 0.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 (40) hide show
  1. package/CHANGELOG.md +29 -4
  2. package/README.md +206 -150
  3. package/data/github-copilot-pricing.json +34 -6
  4. package/dist/copilot-usage-studio/browser/chunk-2SAR2Q6M.js +1 -0
  5. package/dist/copilot-usage-studio/browser/chunk-5JYQJM5G.js +1 -0
  6. package/dist/copilot-usage-studio/browser/chunk-7FKLWMFH.js +1 -0
  7. package/dist/copilot-usage-studio/browser/chunk-EWN3YOHT.js +1 -0
  8. package/dist/copilot-usage-studio/browser/chunk-KS5W2HMH.js +1 -0
  9. package/dist/copilot-usage-studio/browser/chunk-OS2XPCVW.js +2 -0
  10. package/dist/copilot-usage-studio/browser/chunk-U4H425LY.js +1 -0
  11. package/dist/copilot-usage-studio/browser/chunk-VEKXIM47.js +1 -0
  12. package/dist/copilot-usage-studio/browser/chunk-WOIYAEKB.js +4 -0
  13. package/dist/copilot-usage-studio/browser/chunk-XHID4XVE.js +1 -0
  14. package/dist/copilot-usage-studio/browser/index.html +11 -11
  15. package/dist/copilot-usage-studio/browser/main-OZIMQRXC.js +1 -0
  16. package/dist/copilot-usage-studio/browser/styles-7RDDQXQV.css +1 -0
  17. package/docs/copilot-memory.md +91 -0
  18. package/docs/local-deployment.md +119 -13
  19. package/docs/pricing.md +3 -3
  20. package/docs/scanner-api.md +32 -3
  21. package/lib/cli.mjs +15 -1
  22. package/lib/local-runtime.mjs +530 -19
  23. package/lib/scanner-worker.mjs +25 -0
  24. package/package.json +95 -78
  25. package/scripts/pricing-utils.mjs +5 -0
  26. package/scripts/scan-vscode-sessions.mjs +418 -1204
  27. package/scripts/scanner-customization-evidence.mjs +576 -0
  28. package/scripts/scanner-customization-inventory.mjs +1021 -0
  29. package/scripts/scanner-memory.mjs +229 -0
  30. package/scripts/scanner-session-parser.mjs +1237 -0
  31. package/scripts/scanner-traversal.mjs +203 -0
  32. package/scripts/scanner-workspace.mjs +209 -0
  33. package/dist/copilot-usage-studio/browser/chunk-C6VWIY5S.js +0 -1
  34. package/dist/copilot-usage-studio/browser/chunk-DLWQO3VR.js +0 -1
  35. package/dist/copilot-usage-studio/browser/chunk-F6TIG2GE.js +0 -4
  36. package/dist/copilot-usage-studio/browser/chunk-JIP7ONRZ.js +0 -1
  37. package/dist/copilot-usage-studio/browser/chunk-RNKEPBEU.js +0 -1
  38. package/dist/copilot-usage-studio/browser/chunk-Z3XIAKMM.js +0 -1
  39. package/dist/copilot-usage-studio/browser/main-C6XOJRSH.js +0 -2
  40. package/dist/copilot-usage-studio/browser/styles-GZOQ5QIH.css +0 -1
@@ -1,8 +1,18 @@
1
- import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
1
+ import { fork, spawn } from 'node:child_process';
2
+ import {
3
+ appendFileSync,
4
+ createReadStream,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
2
11
  import { createServer } from 'node:http';
3
- import { extname, join, normalize, resolve, sep } from 'node:path';
12
+ import { dirname, extname, join, normalize, resolve, sep } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
4
14
 
5
- import { scanVsCodeSessions, writeSessionData } from './scanner-api.mjs';
15
+ import { writeSessionData } from './scanner-api.mjs';
6
16
 
7
17
  const contentTypes = new Map([
8
18
  ['.css', 'text/css; charset=utf-8'],
@@ -18,6 +28,8 @@ const contentTypes = new Map([
18
28
  ['.woff2', 'font/woff2'],
19
29
  ]);
20
30
 
31
+ const scannerWorkerPath = fileURLToPath(new URL('./scanner-worker.mjs', import.meta.url));
32
+
21
33
  export function createLocalRuntime(options = {}) {
22
34
  const host = options.host ?? '127.0.0.1';
23
35
  const port = Number(options.port ?? 4312);
@@ -27,9 +39,17 @@ export function createLocalRuntime(options = {}) {
27
39
  : resolve(options.seedDataFile ?? 'public/data/sessions.json');
28
40
  const staticDir = resolve(options.staticDir ?? 'dist/copilot-usage-studio/browser');
29
41
  const scanOptions = options.scanOptions ?? {};
30
- const scanner = options.scanner ?? scanVsCodeSessions;
42
+ const scanner = options.scanner ?? scanInChildProcess;
31
43
  const writer = options.writer ?? writeSessionData;
44
+ const openPath = options.openPath ?? openLocalPath;
32
45
  const logger = options.logger ?? console;
46
+ const backendOnly = options.backendOnly === true;
47
+ const firstDataWaitMs = Math.max(0, Number(options.firstDataWaitMs ?? 5000));
48
+ const logFile = options.logFile === null ? '' : resolve(options.logFile ?? join(dirname(dataFile), 'runtime.log'));
49
+ const logEntries = [];
50
+ const progressHistory = [];
51
+ const workspaceProgress = new Map();
52
+ let scanProgress = null;
33
53
 
34
54
  let sessionData = readCachedSessionData(dataFile, logger)
35
55
  ?? (seedDataFile ? readCachedSessionData(seedDataFile, logger) : null);
@@ -39,6 +59,10 @@ export function createLocalRuntime(options = {}) {
39
59
  let lastScanCompletedAt = '';
40
60
  let lastScanDurationMs = 0;
41
61
  let activeScan = null;
62
+ let activeScanController = null;
63
+ let activeScanId = 0;
64
+ let activeScanMode = '';
65
+ let scanSequence = 0;
42
66
 
43
67
  function status() {
44
68
  return {
@@ -46,60 +70,160 @@ export function createLocalRuntime(options = {}) {
46
70
  scanning: Boolean(activeScan),
47
71
  hasData: Boolean(sessionData),
48
72
  sessionCount: sessionData?.sessions?.length ?? 0,
73
+ memoryCount: sessionData?.memories?.length ?? 0,
49
74
  generatedAt: sessionData?.generatedAt ?? '',
50
75
  lastScanStartedAt,
51
76
  lastScanCompletedAt,
52
77
  lastScanDurationMs,
53
78
  lastError,
79
+ activeScanId,
80
+ activeScanMode,
81
+ scanProgress,
82
+ progressHistory: progressHistory.slice(-40),
83
+ workspaceProgress: [...workspaceProgress.values()],
84
+ logFile,
85
+ recentLogs: logEntries.slice(-12),
86
+ };
87
+ }
88
+
89
+ function diagnosticsReport() {
90
+ return {
91
+ status: status(),
92
+ dataFile,
93
+ staticDir,
94
+ seedDataFile,
95
+ logFile,
96
+ scanOptions: summarizeScanOptions(scanOptions),
97
+ progressHistory,
98
+ workspaceProgress: [...workspaceProgress.values()],
99
+ logs: logEntries,
54
100
  };
55
101
  }
56
102
 
57
- function refresh(reason = 'manual') {
103
+ function refresh(reason = 'manual', refreshOptions = {}) {
58
104
  if (activeScan) {
105
+ emitLog('log', `Scan already running; reusing active ${reason} request.`);
59
106
  return activeScan;
60
107
  }
61
108
 
109
+ const scanMode = normalizeScanMode(refreshOptions.mode ?? 'quick');
110
+ const effectiveScanOptions = scanOptionsForMode(scanMode, scanOptions);
62
111
  const started = Date.now();
112
+ const scanId = ++scanSequence;
113
+ activeScanId = scanId;
114
+ activeScanMode = scanMode;
115
+ activeScanController = new AbortController();
63
116
  lastScanStartedAt = new Date(started).toISOString();
64
117
  lastError = '';
65
118
  phase = 'scanning';
119
+ progressHistory.length = 0;
120
+ workspaceProgress.clear();
121
+ scanProgress = rememberProgress({
122
+ stage: 'starting',
123
+ message: `Starting scan #${scanId} (${reason}).`,
124
+ }, scanId, reason, lastScanStartedAt);
125
+ emitLog('log', `Starting local data refresh #${scanId} (${reason}).`);
66
126
 
67
127
  activeScan = Promise.resolve()
68
- .then(() => scanner(scanOptions))
128
+ .then(() =>
129
+ scanner({
130
+ ...effectiveScanOptions,
131
+ signal: activeScanController.signal,
132
+ onProgress: (progress) => {
133
+ scanProgress = rememberProgress(progress, scanId, reason);
134
+ if (progress?.message) {
135
+ emitLog('log', progress.message);
136
+ }
137
+ },
138
+ }),
139
+ )
69
140
  .then((nextSessionData) => {
70
- writer(nextSessionData, dataFile);
71
- sessionData = nextSessionData;
141
+ const finalSessionData = sessionData
142
+ ? scanMode === 'customizations'
143
+ ? mergeCustomizationScan(sessionData, nextSessionData)
144
+ : preserveCustomizationSnapshot(sessionData, nextSessionData)
145
+ : nextSessionData;
146
+ writer(finalSessionData, dataFile);
147
+ sessionData = finalSessionData;
72
148
  phase = 'ready';
73
149
  lastScanCompletedAt = new Date().toISOString();
74
150
  lastScanDurationMs = Date.now() - started;
75
- logger.log(
76
- `Local data refresh (${reason}) imported ${nextSessionData.sessions.length} sessions in ${lastScanDurationMs}ms.`,
151
+ scanProgress = rememberProgress({
152
+ stage: 'complete',
153
+ message: scanMode === 'customizations'
154
+ ? `Customization usage check complete.`
155
+ : `Scan #${scanId} imported ${finalSessionData.sessions.length} sessions.`,
156
+ sessions: finalSessionData.sessions.length,
157
+ }, scanId, reason, lastScanCompletedAt);
158
+ emitLog(
159
+ 'log',
160
+ scanMode === 'customizations'
161
+ ? `Customization usage check #${scanId} (${reason}) completed in ${lastScanDurationMs}ms.`
162
+ : `Local data refresh #${scanId} (${reason}) imported ${finalSessionData.sessions.length} sessions in ${lastScanDurationMs}ms.`,
77
163
  );
78
- return nextSessionData;
164
+ return finalSessionData;
79
165
  })
80
166
  .catch((error) => {
81
- lastError = error instanceof Error ? error.message : String(error);
82
- phase = sessionData ? 'ready' : 'error';
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ const stopped = isScanStoppedError(error);
169
+ lastError = stopped ? 'Scan stopped by user.' : message;
170
+ phase = stopped ? (sessionData ? 'ready' : 'stopped') : (sessionData ? 'ready' : 'error');
83
171
  lastScanCompletedAt = new Date().toISOString();
84
172
  lastScanDurationMs = Date.now() - started;
85
- logger.error(`Local data refresh failed: ${lastError}`);
173
+ scanProgress = rememberProgress({
174
+ stage: stopped ? 'stopped' : 'failed',
175
+ message: stopped ? 'Scan stopped. Existing data was kept.' : lastError,
176
+ }, scanId, reason, lastScanCompletedAt);
177
+ emitLog(stopped ? 'warn' : 'error', `Local data refresh #${scanId} (${reason}) ${stopped ? 'stopped' : 'failed'}: ${lastError}`);
178
+ if (!stopped && error instanceof Error && error.stack) {
179
+ emitLog('error', error.stack);
180
+ }
86
181
  throw error;
87
182
  })
88
183
  .finally(() => {
89
184
  activeScan = null;
185
+ activeScanController = null;
186
+ activeScanId = 0;
187
+ activeScanMode = '';
90
188
  });
91
189
 
92
190
  return activeScan;
93
191
  }
94
192
 
193
+ function cancelScan() {
194
+ if (!activeScan || !activeScanController) {
195
+ return false;
196
+ }
197
+ activeScanController.abort();
198
+ emitLog('warn', 'Local data refresh was stopped by the user.');
199
+ return true;
200
+ }
201
+
95
202
  const server = createServer(async (request, response) => {
96
203
  try {
97
204
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${host}:${port}`}`);
98
205
 
206
+ if (request.method === 'OPTIONS') {
207
+ response.writeHead(204, corsHeaders());
208
+ response.end();
209
+ return;
210
+ }
211
+
99
212
  if (request.method === 'GET' && url.pathname === '/api/status') {
100
213
  return sendJson(response, 200, status());
101
214
  }
102
215
 
216
+ if (request.method === 'GET' && url.pathname === '/api/diagnostics') {
217
+ return sendJson(response, 200, diagnosticsReport());
218
+ }
219
+
220
+ if (request.method === 'GET' && url.pathname === '/api/logs') {
221
+ return sendJson(response, 200, {
222
+ logFile,
223
+ entries: logEntries,
224
+ });
225
+ }
226
+
103
227
  if (request.method === 'GET' && url.pathname === '/api/sessions') {
104
228
  await waitForFirstData();
105
229
  return sessionData
@@ -116,20 +240,38 @@ export function createLocalRuntime(options = {}) {
116
240
 
117
241
  if (request.method === 'POST' && url.pathname === '/api/scan') {
118
242
  try {
119
- const nextSessionData = await refresh('manual');
243
+ const body = await readJsonBody(request);
244
+ const nextSessionData = await refresh('manual', body);
120
245
  return sendJson(response, 200, { sessionData: nextSessionData, status: status() });
121
246
  } catch {
122
247
  return sendJson(response, 500, { error: lastError, status: status() });
123
248
  }
124
249
  }
125
250
 
251
+ if (request.method === 'POST' && url.pathname === '/api/scan/cancel') {
252
+ return sendJson(response, 200, { canceled: cancelScan(), status: status() });
253
+ }
254
+
255
+ const memoryAction = url.pathname.match(/^\/api\/memories\/([a-f0-9]{24})\/open$/);
256
+ if (request.method === 'POST' && memoryAction) {
257
+ const memory = sessionData?.memories?.find((candidate) => candidate.id === memoryAction[1]);
258
+ if (!memory) {
259
+ return sendJson(response, 404, { error: 'Memory file not found in the current scan.' });
260
+ }
261
+
262
+ const body = await readJsonBody(request);
263
+ const action = body?.action === 'reveal' ? 'reveal' : 'open';
264
+ await openPath(memory.sourcePath, action);
265
+ return sendJson(response, 200, { ok: true, action });
266
+ }
267
+
126
268
  if (request.method !== 'GET' && request.method !== 'HEAD') {
127
269
  return sendJson(response, 405, { error: 'Method not allowed.' });
128
270
  }
129
271
 
130
272
  return serveStaticFile(request, response, staticDir, url.pathname);
131
273
  } catch (error) {
132
- logger.error(error);
274
+ emitLog('error', error instanceof Error ? (error.stack ?? error.message) : String(error));
133
275
  return sendJson(response, 500, { error: 'Local runtime request failed.' });
134
276
  }
135
277
  });
@@ -144,27 +286,386 @@ export function createLocalRuntime(options = {}) {
144
286
  server.listen(port, host, () => {
145
287
  server.off('error', rejectListen);
146
288
  const address = server.address();
147
- logger.log(`Copilot Usage Studio local runtime: http://${host}:${address.port}/`);
289
+ emitLog(
290
+ 'log',
291
+ backendOnly
292
+ ? `Copilot Usage Studio backend API: http://${host}:${address.port}/ (used by the dev server)`
293
+ : `Copilot Usage Studio local app: http://${host}:${address.port}/`,
294
+ );
295
+ if (logFile) {
296
+ emitLog('log', `Runtime log file: ${logFile}`);
297
+ }
148
298
  resolveListen(address);
149
299
 
150
300
  if (options.scanOnStart !== false) {
151
- void refresh('startup').catch(() => {});
301
+ void refresh('startup', { mode: options.startupScanMode ?? 'quick' }).catch(() => {});
152
302
  }
153
303
  });
154
304
  }),
155
305
  refresh,
306
+ cancelScan,
156
307
  status,
308
+ diagnostics: diagnosticsReport,
157
309
  };
158
310
 
159
311
  async function waitForFirstData() {
160
312
  if (!sessionData && activeScan) {
161
313
  try {
162
- await activeScan;
314
+ await waitForActiveScanOrTimeout(activeScan, firstDataWaitMs);
163
315
  } catch {
164
316
  // The response below reports the retained empty/error state.
165
317
  }
166
318
  }
167
319
  }
320
+
321
+ function emitLog(level, message) {
322
+ const entry = {
323
+ at: new Date().toISOString(),
324
+ level,
325
+ message: String(message),
326
+ };
327
+ logEntries.push(entry);
328
+ if (logEntries.length > 200) {
329
+ logEntries.splice(0, logEntries.length - 200);
330
+ }
331
+
332
+ if (logFile) {
333
+ try {
334
+ mkdirSync(dirname(logFile), { recursive: true });
335
+ if (!existsSync(logFile)) {
336
+ writeFileSync(logFile, '', 'utf8');
337
+ }
338
+ appendFileSync(logFile, `${entry.at} ${entry.level.toUpperCase()} ${entry.message}\n`, 'utf8');
339
+ } catch {
340
+ // Logging must never prevent the local runtime from starting.
341
+ }
342
+ }
343
+
344
+ const sink = level === 'error' ? logger.error : level === 'warn' ? logger.warn : logger.log;
345
+ if (typeof sink === 'function') {
346
+ sink.call(logger, entry.message);
347
+ }
348
+ }
349
+
350
+ function rememberProgress(progress = {}, scanId, reason, updatedAt = new Date().toISOString()) {
351
+ const event = {
352
+ ...progress,
353
+ scanId,
354
+ reason,
355
+ updatedAt,
356
+ };
357
+ progressHistory.push(event);
358
+ if (progressHistory.length > 200) {
359
+ progressHistory.splice(0, progressHistory.length - 200);
360
+ }
361
+ if (event.workspace || event.workspaceDir) {
362
+ const key = event.workspaceDir || event.workspace;
363
+ const existing = workspaceProgress.get(key) ?? {};
364
+ workspaceProgress.set(key, {
365
+ ...existing,
366
+ workspace: event.workspace ?? existing.workspace ?? '',
367
+ workspaceDir: event.workspaceDir ?? existing.workspaceDir ?? '',
368
+ workspaceIndex: event.workspaceIndex ?? existing.workspaceIndex ?? null,
369
+ workspaceTotal: event.workspaceTotal ?? existing.workspaceTotal ?? null,
370
+ lastStage: event.stage ?? existing.lastStage ?? '',
371
+ message: event.message ?? existing.message ?? '',
372
+ elapsedMs: event.elapsedMs ?? existing.elapsedMs ?? 0,
373
+ updatedAt,
374
+ debugLogFolders: event.debugLogFolders ?? existing.debugLogFolders ?? null,
375
+ chatSnapshots: event.chatSnapshots ?? existing.chatSnapshots ?? null,
376
+ hasMemoryRoot: event.hasMemoryRoot ?? existing.hasMemoryRoot ?? null,
377
+ customizationInventory: event.customizationInventory ?? existing.customizationInventory ?? null,
378
+ total: event.total ?? existing.total ?? null,
379
+ index: event.index ?? existing.index ?? null,
380
+ sessions: event.sessions ?? existing.sessions ?? null,
381
+ memories: event.memories ?? existing.memories ?? null,
382
+ customizations: event.customizations ?? existing.customizations ?? null,
383
+ completed: event.stage === 'workspace-complete' || existing.completed === true,
384
+ });
385
+ }
386
+ return event;
387
+ }
388
+ }
389
+
390
+ function summarizeScanOptions(scanOptions = {}) {
391
+ return {
392
+ roots: Array.isArray(scanOptions.roots) ? scanOptions.roots.map(String) : [],
393
+ customizationWorkspaceFolders: Array.isArray(scanOptions.customizationWorkspaceFolders)
394
+ ? scanOptions.customizationWorkspaceFolders.map(String)
395
+ : [],
396
+ includeCustomizations: scanOptions.includeCustomizations !== false,
397
+ sqlite: scanOptions.sqlite !== false,
398
+ };
399
+ }
400
+
401
+ function normalizeScanMode(mode) {
402
+ return ['quick', 'full', 'customizations'].includes(String(mode)) ? String(mode) : 'quick';
403
+ }
404
+
405
+ function scanOptionsForMode(mode, baseScanOptions = {}) {
406
+ if (mode === 'full' || mode === 'customizations') {
407
+ const { customizationWorkspaceFolders, ...scanOptions } = baseScanOptions;
408
+ if (mode === 'customizations' && Array.isArray(customizationWorkspaceFolders)) {
409
+ return {
410
+ ...scanOptions,
411
+ includeCustomizations: true,
412
+ requireWorkspaceFolders: true,
413
+ workspaceFolders: customizationWorkspaceFolders,
414
+ customizationEvidence: {
415
+ maxSessions: 0,
416
+ maxModelCalls: 0,
417
+ maxElapsedMs: 0,
418
+ maxPartChars: 250_000,
419
+ ...(scanOptions.customizationEvidence ?? {}),
420
+ },
421
+ };
422
+ }
423
+
424
+ return {
425
+ ...scanOptions,
426
+ includeCustomizations: true,
427
+ customizationEvidence: {
428
+ maxSessions: 60,
429
+ maxModelCalls: 500,
430
+ maxElapsedMs: 90_000,
431
+ maxPartChars: 250_000,
432
+ ...(scanOptions.customizationEvidence ?? {}),
433
+ },
434
+ };
435
+ }
436
+
437
+ return {
438
+ ...baseScanOptions,
439
+ includeCustomizations: false,
440
+ };
441
+ }
442
+
443
+ function mergeCustomizationScan(previousData, currentWorkspaceData) {
444
+ const scannedWorkspaces = new Set(
445
+ (currentWorkspaceData.ingestion?.workspaceScans ?? [])
446
+ .map((scan) => scan.workspace)
447
+ .filter(Boolean),
448
+ );
449
+ const sessionsById = new Map((previousData.sessions ?? []).map((session) => [session.id, session]));
450
+ for (const session of currentWorkspaceData.sessions ?? []) {
451
+ sessionsById.set(session.id, session);
452
+ }
453
+ const memoriesById = new Map((previousData.memories ?? []).map((memory) => [memory.id, memory]));
454
+ for (const memory of currentWorkspaceData.memories ?? []) {
455
+ memoriesById.set(memory.id, memory);
456
+ }
457
+ const retainedCustomizations = (previousData.customizations ?? []).filter(
458
+ (customization) => !scannedWorkspaces.has(customization.workspace),
459
+ );
460
+ const customizationsById = new Map(retainedCustomizations.map((customization) => [customization.id, customization]));
461
+ for (const customization of currentWorkspaceData.customizations ?? []) {
462
+ customizationsById.set(customization.id, customization);
463
+ }
464
+ const sessions = [...sessionsById.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
465
+ const memories = [...memoriesById.values()].sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
466
+ const customizations = [...customizationsById.values()].sort((a, b) =>
467
+ b.modifiedAt.localeCompare(a.modifiedAt),
468
+ );
469
+
470
+ return {
471
+ ...previousData,
472
+ generatedAt: currentWorkspaceData.generatedAt,
473
+ ingestion: {
474
+ ...previousData.ingestion,
475
+ ...currentWorkspaceData.ingestion,
476
+ importedSessions: sessions.length,
477
+ importedCustomizations: customizations.length,
478
+ },
479
+ sessions,
480
+ memories,
481
+ customizations,
482
+ };
483
+ }
484
+
485
+ function preserveCustomizationSnapshot(previousData, nextData) {
486
+ const hasPreviousCustomizations = Array.isArray(previousData.customizations) && previousData.customizations.length > 0;
487
+ const nextHasCustomizations = Array.isArray(nextData.customizations) && nextData.customizations.length > 0;
488
+ if (!hasPreviousCustomizations || nextHasCustomizations) {
489
+ return nextData;
490
+ }
491
+
492
+ return {
493
+ ...nextData,
494
+ ingestion: {
495
+ ...nextData.ingestion,
496
+ scannedCustomizationLocations:
497
+ nextData.ingestion?.scannedCustomizationLocations ??
498
+ previousData.ingestion?.scannedCustomizationLocations,
499
+ importedCustomizations:
500
+ nextData.ingestion?.importedCustomizations ??
501
+ previousData.ingestion?.importedCustomizations,
502
+ customizationEvidenceScannedSessions:
503
+ nextData.ingestion?.customizationEvidenceScannedSessions ??
504
+ previousData.ingestion?.customizationEvidenceScannedSessions,
505
+ customizationEvidenceModelCalls:
506
+ nextData.ingestion?.customizationEvidenceModelCalls ??
507
+ previousData.ingestion?.customizationEvidenceModelCalls,
508
+ customizationEvidenceTextParts:
509
+ nextData.ingestion?.customizationEvidenceTextParts ??
510
+ previousData.ingestion?.customizationEvidenceTextParts,
511
+ customizationEvidenceMatchedCustomizations:
512
+ nextData.ingestion?.customizationEvidenceMatchedCustomizations ??
513
+ previousData.ingestion?.customizationEvidenceMatchedCustomizations,
514
+ },
515
+ customizations: previousData.customizations,
516
+ };
517
+ }
518
+
519
+ function waitForActiveScanOrTimeout(activeScan, timeoutMs) {
520
+ if (!timeoutMs) {
521
+ return Promise.resolve();
522
+ }
523
+
524
+ return Promise.race([
525
+ activeScan,
526
+ new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutMs)),
527
+ ]);
528
+ }
529
+
530
+ function isScanStoppedError(error) {
531
+ return /scan stopped by user/i.test(error instanceof Error ? error.message : String(error));
532
+ }
533
+
534
+ function scanInChildProcess(options = {}) {
535
+ const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
536
+ const signal = options.signal;
537
+ const scanOptions = { ...options };
538
+ delete scanOptions.onProgress;
539
+ delete scanOptions.signal;
540
+
541
+ return new Promise((resolveScan, rejectScan) => {
542
+ const child = fork(scannerWorkerPath, {
543
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
544
+ windowsHide: true,
545
+ });
546
+ let settled = false;
547
+ let stderr = '';
548
+
549
+ const abort = () => {
550
+ if (settled) {
551
+ return;
552
+ }
553
+ settled = true;
554
+ child.kill();
555
+ rejectScan(new Error('Scan stopped by user.'));
556
+ };
557
+
558
+ if (signal?.aborted) {
559
+ abort();
560
+ return;
561
+ }
562
+ signal?.addEventListener?.('abort', abort, { once: true });
563
+
564
+ child.stderr?.on('data', (chunk) => {
565
+ stderr = `${stderr}${chunk.toString()}`.slice(-8000);
566
+ });
567
+
568
+ child.on('message', (message) => {
569
+ if (!message || typeof message !== 'object') {
570
+ return;
571
+ }
572
+ if (message.type === 'progress') {
573
+ onProgress(message.event ?? {});
574
+ return;
575
+ }
576
+ if (message.type === 'result') {
577
+ settled = true;
578
+ signal?.removeEventListener?.('abort', abort);
579
+ resolveScan(message.sessionData);
580
+ child.kill();
581
+ return;
582
+ }
583
+ if (message.type === 'error') {
584
+ settled = true;
585
+ signal?.removeEventListener?.('abort', abort);
586
+ const error = new Error(message.error?.message ?? 'Scanner worker failed.');
587
+ error.stack = message.error?.stack || error.stack;
588
+ rejectScan(error);
589
+ child.kill();
590
+ }
591
+ });
592
+
593
+ child.once('error', (error) => {
594
+ if (!settled) {
595
+ settled = true;
596
+ signal?.removeEventListener?.('abort', abort);
597
+ rejectScan(error);
598
+ }
599
+ });
600
+
601
+ child.once('exit', (code, exitSignal) => {
602
+ if (!settled) {
603
+ settled = true;
604
+ options.signal?.removeEventListener?.('abort', abort);
605
+ rejectScan(
606
+ new Error(
607
+ `Scanner worker exited before returning data (${exitSignal ?? `code ${code}`}).${stderr ? `\n${stderr}` : ''}`,
608
+ ),
609
+ );
610
+ }
611
+ });
612
+
613
+ child.send({ type: 'scan', options: scanOptions });
614
+ });
615
+ }
616
+
617
+ function readJsonBody(request) {
618
+ return new Promise((resolveBody, rejectBody) => {
619
+ const chunks = [];
620
+ let size = 0;
621
+ request.on('data', (chunk) => {
622
+ size += chunk.length;
623
+ if (size > 16 * 1024) {
624
+ rejectBody(new Error('Request body is too large.'));
625
+ request.destroy();
626
+ return;
627
+ }
628
+ chunks.push(chunk);
629
+ });
630
+ request.on('end', () => {
631
+ if (!chunks.length) {
632
+ resolveBody({});
633
+ return;
634
+ }
635
+ try {
636
+ resolveBody(JSON.parse(Buffer.concat(chunks).toString('utf8')));
637
+ } catch {
638
+ rejectBody(new Error('Request body must be valid JSON.'));
639
+ }
640
+ });
641
+ request.on('error', rejectBody);
642
+ });
643
+ }
644
+
645
+ function openLocalPath(path, action) {
646
+ const target = resolve(path);
647
+ let command;
648
+ let args;
649
+
650
+ if (process.platform === 'win32') {
651
+ command = 'explorer.exe';
652
+ args = action === 'reveal' ? ['/select,', target] : [target];
653
+ } else if (process.platform === 'darwin') {
654
+ command = 'open';
655
+ args = action === 'reveal' ? ['-R', target] : [target];
656
+ } else {
657
+ command = 'xdg-open';
658
+ args = [action === 'reveal' ? dirname(target) : target];
659
+ }
660
+
661
+ return new Promise((resolveOpen, rejectOpen) => {
662
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
663
+ child.once('error', rejectOpen);
664
+ child.once('spawn', () => {
665
+ child.unref();
666
+ resolveOpen();
667
+ });
668
+ });
168
669
  }
169
670
 
170
671
  function readCachedSessionData(dataFile, logger) {
@@ -184,6 +685,7 @@ function readCachedSessionData(dataFile, logger) {
184
685
  function sendJson(response, statusCode, body) {
185
686
  const payload = JSON.stringify(body);
186
687
  response.writeHead(statusCode, {
688
+ ...corsHeaders(),
187
689
  'cache-control': 'no-store',
188
690
  'content-length': Buffer.byteLength(payload),
189
691
  'content-type': 'application/json; charset=utf-8',
@@ -191,6 +693,15 @@ function sendJson(response, statusCode, body) {
191
693
  response.end(payload);
192
694
  }
193
695
 
696
+ function corsHeaders() {
697
+ return {
698
+ 'access-control-allow-headers': 'content-type',
699
+ 'access-control-allow-methods': 'GET,POST,OPTIONS',
700
+ 'access-control-allow-origin': '*',
701
+ 'access-control-max-age': '600',
702
+ };
703
+ }
704
+
194
705
  function serveStaticFile(request, response, staticDir, pathname) {
195
706
  if (!existsSync(staticDir)) {
196
707
  return sendJson(response, 404, {