groove-dev 0.27.101 → 0.27.103

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 (59) hide show
  1. package/moe-training/client/domain-tagger.js +205 -0
  2. package/moe-training/client/edit-normalizer.js +188 -0
  3. package/moe-training/client/envelope-builder.js +1 -1
  4. package/moe-training/client/parsers/claude-code.js +56 -9
  5. package/moe-training/client/parsers/codex.js +25 -5
  6. package/moe-training/client/parsers/gemini.js +21 -2
  7. package/moe-training/client/parsers/grok.js +18 -0
  8. package/moe-training/client/trajectory-capture.js +95 -3
  9. package/moe-training/server/routes/ingest.js +26 -0
  10. package/moe-training/server/verifier.js +34 -0
  11. package/moe-training/shared/constants.js +9 -0
  12. package/moe-training/shared/envelope-schema.js +128 -2
  13. package/moe-training/test/client/domain-tagger.test.js +203 -0
  14. package/moe-training/test/client/edit-normalizer.test.js +376 -0
  15. package/moe-training/test/client/envelope-builder.test.js +28 -0
  16. package/moe-training/test/client/parsers/claude-code.test.js +248 -38
  17. package/moe-training/test/client/parsers/codex.test.js +2 -0
  18. package/moe-training/test/client/parsers/gemini.test.js +2 -0
  19. package/moe-training/test/client/trajectory-capture.test.js +345 -0
  20. package/moe-training/test/server/verifier.test.js +94 -0
  21. package/moe-training/test/shared/envelope-schema.test.js +291 -0
  22. package/node_modules/@groove-dev/cli/package.json +1 -1
  23. package/node_modules/@groove-dev/daemon/package.json +1 -1
  24. package/node_modules/@groove-dev/daemon/src/preview.js +148 -2
  25. package/node_modules/@groove-dev/gui/package.json +1 -1
  26. package/node_modules/moe-training/client/domain-tagger.js +205 -0
  27. package/node_modules/moe-training/client/edit-normalizer.js +188 -0
  28. package/node_modules/moe-training/client/envelope-builder.js +1 -1
  29. package/node_modules/moe-training/client/parsers/claude-code.js +56 -9
  30. package/node_modules/moe-training/client/parsers/codex.js +25 -5
  31. package/node_modules/moe-training/client/parsers/gemini.js +21 -2
  32. package/node_modules/moe-training/client/parsers/grok.js +18 -0
  33. package/node_modules/moe-training/client/trajectory-capture.js +95 -3
  34. package/node_modules/moe-training/server/routes/ingest.js +26 -0
  35. package/node_modules/moe-training/server/verifier.js +34 -0
  36. package/node_modules/moe-training/shared/constants.js +9 -0
  37. package/node_modules/moe-training/shared/envelope-schema.js +128 -2
  38. package/node_modules/moe-training/test/client/domain-tagger.test.js +203 -0
  39. package/node_modules/moe-training/test/client/edit-normalizer.test.js +376 -0
  40. package/node_modules/moe-training/test/client/envelope-builder.test.js +28 -0
  41. package/node_modules/moe-training/test/client/parsers/claude-code.test.js +248 -38
  42. package/node_modules/moe-training/test/client/parsers/codex.test.js +2 -0
  43. package/node_modules/moe-training/test/client/parsers/gemini.test.js +2 -0
  44. package/node_modules/moe-training/test/client/trajectory-capture.test.js +345 -0
  45. package/node_modules/moe-training/test/server/verifier.test.js +94 -0
  46. package/node_modules/moe-training/test/shared/envelope-schema.test.js +291 -0
  47. package/package.json +1 -1
  48. package/packages/cli/package.json +1 -1
  49. package/packages/daemon/package.json +1 -1
  50. package/packages/daemon/src/preview.js +148 -2
  51. package/packages/gui/package.json +1 -1
  52. package/packages/launch-page/dist/assets/index-Bo186ysq.js +4180 -0
  53. package/packages/launch-page/dist/assets/index-CP4c4yxe.css +1 -0
  54. package/packages/launch-page/dist/index.html +2 -2
  55. package/packages/launch-page/src/App.css +438 -137
  56. package/packages/launch-page/src/App.tsx +171 -123
  57. package/packages/launch-page/src/index.css +9 -2
  58. package/packages/launch-page/dist/assets/index-BK3nAvHG.js +0 -4180
  59. package/packages/launch-page/dist/assets/index-jrLVZW5U.css +0 -2
@@ -225,6 +225,100 @@ describe('EnvelopeVerifier', () => {
225
225
  assert.equal(result.valid, false);
226
226
  assert.ok(result.reason.includes('HMAC'));
227
227
  });
228
+
229
+ // --- verifyFeedback ---
230
+
231
+ it('verifyFeedback accepts valid USER_FEEDBACK envelope', () => {
232
+ const envelope = {
233
+ envelope_id: 'env_fb_1',
234
+ session_id: sessionId,
235
+ type: 'USER_FEEDBACK',
236
+ feedback: {
237
+ signal: 'accepted',
238
+ timestamp: Date.now() / 1000,
239
+ context: 'completed with no interventions',
240
+ target_step: 10,
241
+ revision_rounds: 0,
242
+ delta_summary: null,
243
+ },
244
+ };
245
+
246
+ const forHmac = { ...envelope };
247
+ const envelopeBytes = JSON.stringify(forHmac);
248
+ const hmac = signEnvelope(sharedSecret, envelopeBytes, 0);
249
+ envelope.attestation = { session_hmac: hmac, sequence: 0, app_version_hash: VALID_APP_HASH };
250
+
251
+ const result = verifier.verifyFeedback(envelope);
252
+ assert.equal(result.valid, true);
253
+ });
254
+
255
+ it('verifyFeedback rejects unknown session_id', () => {
256
+ const envelope = {
257
+ envelope_id: 'env_fb_2',
258
+ session_id: 'sess_nonexistent',
259
+ type: 'USER_FEEDBACK',
260
+ feedback: { signal: 'accepted', timestamp: Date.now() / 1000 },
261
+ attestation: { session_hmac: 'a'.repeat(64), sequence: 0, app_version_hash: VALID_APP_HASH },
262
+ };
263
+ const result = verifier.verifyFeedback(envelope);
264
+ assert.equal(result.valid, false);
265
+ assert.ok(result.reason.includes('unknown session_id'));
266
+ });
267
+
268
+ it('verifyFeedback rejects missing attestation', () => {
269
+ const envelope = {
270
+ envelope_id: 'env_fb_3',
271
+ session_id: sessionId,
272
+ type: 'USER_FEEDBACK',
273
+ feedback: { signal: 'accepted', timestamp: Date.now() / 1000 },
274
+ };
275
+ const result = verifier.verifyFeedback(envelope);
276
+ assert.equal(result.valid, false);
277
+ assert.ok(result.reason.includes('attestation'));
278
+ });
279
+
280
+ it('verifyFeedback rejects tampered HMAC', () => {
281
+ const envelope = {
282
+ envelope_id: 'env_fb_4',
283
+ session_id: sessionId,
284
+ type: 'USER_FEEDBACK',
285
+ feedback: { signal: 'accepted', timestamp: Date.now() / 1000 },
286
+ attestation: { session_hmac: 'f'.repeat(64), sequence: 0, app_version_hash: VALID_APP_HASH },
287
+ };
288
+ const result = verifier.verifyFeedback(envelope);
289
+ assert.equal(result.valid, false);
290
+ assert.ok(result.reason.includes('HMAC'));
291
+ });
292
+
293
+ it('verifyFeedback rejects invalid signal via schema', () => {
294
+ const envelope = {
295
+ envelope_id: 'env_fb_5',
296
+ session_id: sessionId,
297
+ type: 'USER_FEEDBACK',
298
+ feedback: { signal: 'thumbs_up', timestamp: Date.now() / 1000 },
299
+ };
300
+
301
+ const forHmac = { ...envelope };
302
+ const envelopeBytes = JSON.stringify(forHmac);
303
+ const hmac = signEnvelope(sharedSecret, envelopeBytes, 0);
304
+ envelope.attestation = { session_hmac: hmac, sequence: 0, app_version_hash: VALID_APP_HASH };
305
+
306
+ const result = verifier.verifyFeedback(envelope);
307
+ assert.equal(result.valid, false);
308
+ assert.ok(result.reason.includes('schema'));
309
+ });
310
+
311
+ it('verifyFeedback rejects missing session_id', () => {
312
+ const envelope = {
313
+ envelope_id: 'env_fb_6',
314
+ type: 'USER_FEEDBACK',
315
+ feedback: { signal: 'accepted', timestamp: Date.now() / 1000 },
316
+ attestation: { session_hmac: 'a'.repeat(64), sequence: 0, app_version_hash: VALID_APP_HASH },
317
+ };
318
+ const result = verifier.verifyFeedback(envelope);
319
+ assert.equal(result.valid, false);
320
+ assert.ok(result.reason.includes('session_id'));
321
+ });
228
322
  });
229
323
 
230
324
  function verifyClose(verifier, envelope) {
@@ -3,6 +3,7 @@
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { validateEnvelope, STEP_TYPES } from '../../shared/envelope-schema.js';
6
+ import { TRAINING_EXCLUSION_REASONS } from '../../shared/constants.js';
6
7
 
7
8
  const VALID_HMAC = 'a'.repeat(64);
8
9
  const VALID_APP_HASH = 'b'.repeat(64);
@@ -89,6 +90,17 @@ describe('envelope-schema', () => {
89
90
  }
90
91
  });
91
92
 
93
+ it('edit step type is valid', () => {
94
+ const env = validEnvelope();
95
+ env.trajectory_log = [{
96
+ step: 1, type: 'edit', timestamp: Date.now() / 1000,
97
+ file_path: 'index.html', edit_type: 'create', content: '<html></html>',
98
+ token_count: 5,
99
+ }];
100
+ const result = validateEnvelope(env);
101
+ assert.equal(result.valid, true);
102
+ });
103
+
92
104
  // --- New security tests ---
93
105
 
94
106
  it('rejects trajectory_log with > 500 steps', () => {
@@ -348,4 +360,283 @@ describe('envelope-schema', () => {
348
360
  assert.equal(result.valid, false);
349
361
  assert.ok(result.errors.some(e => e.includes('session_hmac')));
350
362
  });
363
+
364
+ // --- Observation truncation fields ---
365
+
366
+ it('accepts observation step with truncated and original_token_count', () => {
367
+ const env = validEnvelope();
368
+ env.trajectory_log.push({
369
+ step: 3, type: 'observation', timestamp: Date.now() / 1000,
370
+ content: 'output', token_count: 100, truncated: false, original_token_count: 100,
371
+ });
372
+ const result = validateEnvelope(env);
373
+ assert.equal(result.valid, true);
374
+ });
375
+
376
+ it('accepts observation step with truncated=true', () => {
377
+ const env = validEnvelope();
378
+ env.trajectory_log.push({
379
+ step: 3, type: 'observation', timestamp: Date.now() / 1000,
380
+ content: 'output...', token_count: 4096, truncated: true, original_token_count: 9000,
381
+ });
382
+ const result = validateEnvelope(env);
383
+ assert.equal(result.valid, true);
384
+ });
385
+
386
+ it('rejects non-boolean truncated field', () => {
387
+ const env = validEnvelope();
388
+ env.trajectory_log[0].truncated = 'yes';
389
+ const result = validateEnvelope(env);
390
+ assert.equal(result.valid, false);
391
+ assert.ok(result.errors.some(e => e.includes('truncated')));
392
+ });
393
+
394
+ it('rejects negative original_token_count', () => {
395
+ const env = validEnvelope();
396
+ env.trajectory_log[0].original_token_count = -5;
397
+ const result = validateEnvelope(env);
398
+ assert.equal(result.valid, false);
399
+ assert.ok(result.errors.some(e => e.includes('original_token_count')));
400
+ });
401
+
402
+ it('steps without truncation fields still validate (backward compat)', () => {
403
+ const env = validEnvelope();
404
+ assert.equal(env.trajectory_log[0].truncated, undefined);
405
+ const result = validateEnvelope(env);
406
+ assert.equal(result.valid, true);
407
+ });
408
+
409
+ // --- domain_tags ---
410
+
411
+ it('accepts null domain_tags in metadata', () => {
412
+ const env = validEnvelope();
413
+ env.metadata.domain_tags = null;
414
+ const result = validateEnvelope(env);
415
+ assert.equal(result.valid, true);
416
+ });
417
+
418
+ it('accepts absent domain_tags in metadata (backward compat)', () => {
419
+ const env = validEnvelope();
420
+ assert.equal(env.metadata.domain_tags, undefined);
421
+ const result = validateEnvelope(env);
422
+ assert.equal(result.valid, true);
423
+ });
424
+
425
+ it('accepts valid domain_tags object', () => {
426
+ const env = validEnvelope();
427
+ env.metadata.domain_tags = {
428
+ primary: { domain: 'python', confidence: 0.42 },
429
+ secondary: { domain: 'data_science_ml', confidence: 0.23 },
430
+ tertiary: { domain: 'devops_docker', confidence: 0.11 },
431
+ };
432
+ const result = validateEnvelope(env);
433
+ assert.equal(result.valid, true);
434
+ });
435
+
436
+ it('rejects domain_tags with invalid confidence', () => {
437
+ const env = validEnvelope();
438
+ env.metadata.domain_tags = {
439
+ primary: { domain: 'python', confidence: 1.5 },
440
+ secondary: { domain: 'rust', confidence: 0.2 },
441
+ tertiary: { domain: 'react_frontend', confidence: 0.1 },
442
+ };
443
+ const result = validateEnvelope(env);
444
+ assert.equal(result.valid, false);
445
+ assert.ok(result.errors.some(e => e.includes('confidence')));
446
+ });
447
+
448
+ it('rejects domain_tags missing tertiary', () => {
449
+ const env = validEnvelope();
450
+ env.metadata.domain_tags = {
451
+ primary: { domain: 'python', confidence: 0.4 },
452
+ secondary: { domain: 'rust', confidence: 0.2 },
453
+ };
454
+ const result = validateEnvelope(env);
455
+ assert.equal(result.valid, false);
456
+ assert.ok(result.errors.some(e => e.includes('tertiary')));
457
+ });
458
+
459
+ // --- leaf_context ---
460
+
461
+ it('accepts null leaf_context in metadata', () => {
462
+ const env = validEnvelope();
463
+ env.metadata.leaf_context = null;
464
+ const result = validateEnvelope(env);
465
+ assert.equal(result.valid, true);
466
+ });
467
+
468
+ it('accepts absent leaf_context in metadata (backward compat)', () => {
469
+ const env = validEnvelope();
470
+ assert.equal(env.metadata.leaf_context, undefined);
471
+ const result = validateEnvelope(env);
472
+ assert.equal(result.valid, true);
473
+ });
474
+
475
+ it('accepts valid leaf_context object', () => {
476
+ const env = validEnvelope();
477
+ env.metadata.leaf_context = {
478
+ leaf_id: 'python_expert_v3', leaf_version: '1.2.0',
479
+ confidence_at_route: 0.42, chassis_model: 'Qwen/Qwen3-0.6B',
480
+ };
481
+ const result = validateEnvelope(env);
482
+ assert.equal(result.valid, true);
483
+ });
484
+
485
+ it('rejects leaf_context with invalid confidence_at_route', () => {
486
+ const env = validEnvelope();
487
+ env.metadata.leaf_context = {
488
+ leaf_id: 'test', leaf_version: '1.0', confidence_at_route: 1.5, chassis_model: 'test',
489
+ };
490
+ const result = validateEnvelope(env);
491
+ assert.equal(result.valid, false);
492
+ assert.ok(result.errors.some(e => e.includes('confidence_at_route')));
493
+ });
494
+
495
+ // --- Quality tier in SESSION_CLOSE ---
496
+
497
+ it('SESSION_CLOSE accepts quality_tier and training fields', () => {
498
+ const close = {
499
+ envelope_id: 'env_close-qt',
500
+ session_id: 'sess_test-qt',
501
+ type: 'SESSION_CLOSE',
502
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
503
+ outcome: {
504
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
505
+ quality_tier: 'TIER_A', quality_tier_reason: 'high_quality_no_errors',
506
+ training_eligible: true, training_exclusion_reason: null,
507
+ },
508
+ };
509
+ const result = validateEnvelope(close);
510
+ assert.equal(result.valid, true);
511
+ });
512
+
513
+ it('SESSION_CLOSE rejects invalid quality_tier', () => {
514
+ const close = {
515
+ envelope_id: 'env_close-qt2',
516
+ session_id: 'sess_test-qt2',
517
+ type: 'SESSION_CLOSE',
518
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
519
+ outcome: {
520
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
521
+ quality_tier: 'TIER_Z',
522
+ },
523
+ };
524
+ const result = validateEnvelope(close);
525
+ assert.equal(result.valid, false);
526
+ assert.ok(result.errors.some(e => e.includes('quality_tier')));
527
+ });
528
+
529
+ it('SESSION_CLOSE rejects invalid training_exclusion_reason', () => {
530
+ const close = {
531
+ envelope_id: 'env_close-te',
532
+ session_id: 'sess_test-te',
533
+ type: 'SESSION_CLOSE',
534
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
535
+ outcome: {
536
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
537
+ training_eligible: false, training_exclusion_reason: 'bad_vibes',
538
+ },
539
+ };
540
+ const result = validateEnvelope(close);
541
+ assert.equal(result.valid, false);
542
+ assert.ok(result.errors.some(e => e.includes('training_exclusion_reason')));
543
+ });
544
+
545
+ it('SESSION_CLOSE rejects non-boolean training_eligible', () => {
546
+ const close = {
547
+ envelope_id: 'env_close-te2',
548
+ session_id: 'sess_test-te2',
549
+ type: 'SESSION_CLOSE',
550
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
551
+ outcome: {
552
+ status: 'SUCCESS', total_steps: 10, total_chunks: 1,
553
+ training_eligible: 'yes',
554
+ },
555
+ };
556
+ const result = validateEnvelope(close);
557
+ assert.equal(result.valid, false);
558
+ assert.ok(result.errors.some(e => e.includes('training_eligible')));
559
+ });
560
+
561
+ it('SESSION_CLOSE without new fields still validates (backward compat)', () => {
562
+ const close = {
563
+ envelope_id: 'env_close-bc',
564
+ session_id: 'sess_test-bc',
565
+ type: 'SESSION_CLOSE',
566
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
567
+ outcome: { status: 'SUCCESS', total_steps: 10, total_chunks: 1 },
568
+ };
569
+ const result = validateEnvelope(close);
570
+ assert.equal(result.valid, true);
571
+ });
572
+
573
+ // --- USER_FEEDBACK validation ---
574
+
575
+ it('valid USER_FEEDBACK passes', () => {
576
+ const feedback = {
577
+ envelope_id: 'env_fb_1',
578
+ session_id: 'sess_fb_1',
579
+ type: 'USER_FEEDBACK',
580
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
581
+ feedback: {
582
+ signal: 'accepted', timestamp: Date.now() / 1000,
583
+ context: 'user ran code without modifications',
584
+ target_step: 10, revision_rounds: 0, delta_summary: null,
585
+ },
586
+ };
587
+ const result = validateEnvelope(feedback);
588
+ assert.equal(result.valid, true);
589
+ });
590
+
591
+ it('USER_FEEDBACK rejects invalid signal', () => {
592
+ const feedback = {
593
+ envelope_id: 'env_fb_2',
594
+ session_id: 'sess_fb_2',
595
+ type: 'USER_FEEDBACK',
596
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
597
+ feedback: { signal: 'thumbs_up', timestamp: Date.now() / 1000 },
598
+ };
599
+ const result = validateEnvelope(feedback);
600
+ assert.equal(result.valid, false);
601
+ assert.ok(result.errors.some(e => e.includes('signal')));
602
+ });
603
+
604
+ it('USER_FEEDBACK rejects missing feedback object', () => {
605
+ const feedback = {
606
+ envelope_id: 'env_fb_3',
607
+ session_id: 'sess_fb_3',
608
+ type: 'USER_FEEDBACK',
609
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
610
+ };
611
+ const result = validateEnvelope(feedback);
612
+ assert.equal(result.valid, false);
613
+ assert.ok(result.errors.some(e => e.includes('feedback')));
614
+ });
615
+
616
+ it('USER_FEEDBACK accepts all valid signal types', () => {
617
+ for (const signal of ['accepted', 'modified', 'rejected', 'iterated']) {
618
+ const feedback = {
619
+ envelope_id: `env_fb_${signal}`,
620
+ session_id: `sess_fb_${signal}`,
621
+ type: 'USER_FEEDBACK',
622
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
623
+ feedback: { signal, timestamp: Date.now() / 1000 },
624
+ };
625
+ const result = validateEnvelope(feedback);
626
+ assert.equal(result.valid, true, `Signal "${signal}" should be valid`);
627
+ }
628
+ });
629
+
630
+ it('USER_FEEDBACK rejects negative revision_rounds', () => {
631
+ const feedback = {
632
+ envelope_id: 'env_fb_neg',
633
+ session_id: 'sess_fb_neg',
634
+ type: 'USER_FEEDBACK',
635
+ attestation: { session_hmac: VALID_HMAC, sequence: 0, app_version_hash: VALID_APP_HASH },
636
+ feedback: { signal: 'iterated', timestamp: Date.now() / 1000, revision_rounds: -1 },
637
+ };
638
+ const result = validateEnvelope(feedback);
639
+ assert.equal(result.valid, false);
640
+ assert.ok(result.errors.some(e => e.includes('revision_rounds')));
641
+ });
351
642
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.101",
3
+ "version": "0.27.103",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.101",
3
+ "version": "0.27.103",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  // kills the previous one. Previews are also killed on team delete and on
12
12
  // daemon shutdown.
13
13
 
14
- import { spawn as cpSpawn } from 'child_process';
14
+ import { spawn as cpSpawn, execSync } from 'child_process';
15
15
  import { resolve, extname } from 'path';
16
16
  import { existsSync, readFileSync, statSync } from 'fs';
17
17
  import { createServer } from 'http';
@@ -101,11 +101,55 @@ export class PreviewService {
101
101
  return result;
102
102
  }
103
103
 
104
+ const installResult = this._ensureDependencies(teamId, baseDir);
105
+ if (installResult?.failed) {
106
+ this.daemon.audit?.log('preview.failed', { teamId, reason: installResult.reason });
107
+ return { launched: false, reason: installResult.reason };
108
+ }
109
+
104
110
  let result;
105
111
  if (preview.kind === 'static-html') {
106
- result = await this._launchStatic(teamId, baseDir, preview);
112
+ if (this._needsBuild(baseDir, preview)) {
113
+ const buildResult = this._runBuild(teamId, baseDir);
114
+ if (buildResult?.failed) {
115
+ this.daemon.audit?.log('preview.failed', { teamId, reason: buildResult.reason });
116
+ return { launched: false, reason: buildResult.reason };
117
+ }
118
+ const distDir = resolve(baseDir, 'dist');
119
+ if (existsSync(distDir)) {
120
+ result = await this._launchStatic(teamId, distDir, { ...preview, openPath: preview.openPath || 'index.html' });
121
+ } else {
122
+ result = await this._launchStatic(teamId, baseDir, preview);
123
+ }
124
+ } else {
125
+ result = await this._launchStatic(teamId, baseDir, preview);
126
+ }
107
127
  } else if (preview.kind === 'dev-server') {
128
+ if (this._needsPreBuild(baseDir)) {
129
+ const preBuild = this._runBuild(teamId, baseDir);
130
+ if (preBuild?.failed) {
131
+ this.daemon.audit?.log('preview.prebuild-failed', { teamId, reason: preBuild.reason });
132
+ }
133
+ }
108
134
  result = await this._launchDevServer(teamId, baseDir, preview);
135
+ // Fallback: if dev-server failed, try building and serving statically
136
+ if (!result.launched) {
137
+ const pkgPath = resolve(baseDir, 'package.json');
138
+ if (existsSync(pkgPath)) {
139
+ try {
140
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
141
+ if (pkg.scripts?.build) {
142
+ const buildResult = this._runBuild(teamId, baseDir);
143
+ if (!buildResult?.failed) {
144
+ const distDir = resolve(baseDir, 'dist');
145
+ if (existsSync(resolve(distDir, 'index.html'))) {
146
+ result = await this._launchStatic(teamId, distDir, { ...preview, openPath: 'index.html' });
147
+ }
148
+ }
149
+ }
150
+ } catch { /* fallback failed, keep original error */ }
151
+ }
152
+ }
109
153
  } else {
110
154
  result = { launched: false, reason: `unknown_kind: ${preview.kind}` };
111
155
  }
@@ -118,6 +162,99 @@ export class PreviewService {
118
162
  return result;
119
163
  }
120
164
 
165
+ _ensureDependencies(teamId, baseDir) {
166
+ const pkgPath = resolve(baseDir, 'package.json');
167
+ const nodeModules = resolve(baseDir, 'node_modules');
168
+ if (!existsSync(pkgPath) || existsSync(nodeModules)) return null;
169
+ try {
170
+ console.log(`[Groove:Preview] Running npm install in ${baseDir}`);
171
+ this.daemon.audit?.log('preview.npm-install', { teamId, baseDir });
172
+ execSync('npm install', { cwd: baseDir, timeout: 120_000, stdio: 'pipe' });
173
+ return null;
174
+ } catch (err) {
175
+ return { failed: true, reason: `npm install failed: ${err.message?.slice(0, 300)}` };
176
+ }
177
+ }
178
+
179
+ _needsBuild(baseDir, preview) {
180
+ const pkgPath = resolve(baseDir, 'package.json');
181
+ let hasBuildScript = false;
182
+ try {
183
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
184
+ hasBuildScript = !!pkg.scripts?.build;
185
+ } catch { /* no package.json or malformed */ }
186
+
187
+ const distDir = resolve(baseDir, 'dist');
188
+ const distExists = existsSync(distDir);
189
+
190
+ // Primary: build script exists and dist/ doesn't
191
+ if (hasBuildScript && !distExists) return true;
192
+
193
+ // Stale check: dist/ exists but package.json is newer than dist/index.html
194
+ if (hasBuildScript && distExists) {
195
+ const distIndex = resolve(distDir, 'index.html');
196
+ if (existsSync(distIndex) && existsSync(pkgPath)) {
197
+ try {
198
+ const distMtime = statSync(distIndex).mtimeMs;
199
+ const pkgMtime = statSync(pkgPath).mtimeMs;
200
+ if (pkgMtime > distMtime) return true;
201
+ } catch { /* ignore stat errors */ }
202
+ }
203
+ }
204
+
205
+ // Secondary: entry file references .tsx/.jsx sources (needs transpilation)
206
+ const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
207
+ const entryFile = resolve(baseDir, openPath);
208
+ if (existsSync(entryFile)) {
209
+ try {
210
+ const html = readFileSync(entryFile, 'utf8');
211
+ if (/src=["'][^"']*\.(tsx?|jsx)["']/i.test(html)) return true;
212
+ } catch { /* ignore */ }
213
+ }
214
+
215
+ // Entry file missing — check if a build might create it
216
+ if (!existsSync(entryFile) && hasBuildScript) {
217
+ const frameworkConfigs = ['vite.config', 'next.config', 'webpack.config'];
218
+ for (const cfg of frameworkConfigs) {
219
+ for (const ext of ['.js', '.ts', '.mjs', '.cjs']) {
220
+ if (existsSync(resolve(baseDir, cfg + ext))) return true;
221
+ }
222
+ }
223
+ }
224
+
225
+ return false;
226
+ }
227
+
228
+ _needsPreBuild(baseDir) {
229
+ const pkgPath = resolve(baseDir, 'package.json');
230
+ if (!existsSync(pkgPath)) return false;
231
+ try {
232
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
233
+ const startScript = pkg.scripts?.start || '';
234
+ if (/\bnext\s+start\b/.test(startScript)) return true;
235
+ if (/\bserve\b/.test(startScript) && !pkg.scripts?.dev) return true;
236
+ if (/\bhttp-server\b/.test(startScript)) return true;
237
+ } catch { /* ignore */ }
238
+ return false;
239
+ }
240
+
241
+ _runBuild(teamId, baseDir) {
242
+ const pkgPath = resolve(baseDir, 'package.json');
243
+ if (!existsSync(pkgPath)) return { failed: true, reason: 'no package.json for build' };
244
+ try {
245
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
246
+ if (!pkg.scripts?.build) return { failed: true, reason: 'no build script' };
247
+ } catch { return { failed: true, reason: 'malformed package.json' }; }
248
+ try {
249
+ console.log(`[Groove:Preview] Running npm run build in ${baseDir}`);
250
+ this.daemon.audit?.log('preview.build', { teamId, baseDir });
251
+ execSync('npm run build', { cwd: baseDir, timeout: 120_000, stdio: 'pipe' });
252
+ return null;
253
+ } catch (err) {
254
+ return { failed: true, reason: `build failed: ${err.message?.slice(0, 300)}` };
255
+ }
256
+ }
257
+
121
258
  _launchStatic(teamId, baseDir, preview) {
122
259
  const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
123
260
  const entryFile = resolve(baseDir, openPath);
@@ -130,6 +267,15 @@ export class PreviewService {
130
267
  const filePath = resolve(baseDir, rel);
131
268
  if (!filePath.startsWith(baseDir)) { res.statusCode = 403; return res.end(); }
132
269
  if (!existsSync(filePath) || !statSync(filePath).isFile()) {
270
+ // SPA fallback: serve index.html for HTML requests (client-side routing)
271
+ const acceptsHtml = (req.headers.accept || '').includes('text/html');
272
+ if (acceptsHtml) {
273
+ const fallback = resolve(baseDir, openPath);
274
+ if (existsSync(fallback) && statSync(fallback).isFile()) {
275
+ res.setHeader('Content-Type', 'text/html');
276
+ return res.end(readFileSync(fallback));
277
+ }
278
+ }
133
279
  res.statusCode = 404; return res.end('Not found');
134
280
  }
135
281
  res.setHeader('Content-Type', mimeLookup(extname(filePath)) || 'application/octet-stream');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/gui",
3
- "version": "0.27.101",
3
+ "version": "0.27.103",
4
4
  "description": "GROOVE GUI — visual agent control plane",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",