@principles/pd-cli 1.113.0 → 1.114.0

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.
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
3
  const mockGetArtifactById = vi.fn();
4
4
  const mockClose = vi.fn().mockResolvedValue(undefined);
5
+ const mockApprovalEdit = vi.fn();
6
+ const mockApprovalGetById = vi.fn();
5
7
 
6
8
  vi.mock('../../src/resolve-workspace.js', () => ({
7
9
  resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
@@ -32,11 +34,29 @@ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
32
34
  return {
33
35
  findByArtifactId: vi.fn().mockResolvedValue(null),
34
36
  insert: vi.fn().mockResolvedValue(undefined),
37
+ deactivateActivation: vi.fn().mockResolvedValue(true),
38
+ listPromptActivations: vi.fn().mockResolvedValue([
39
+ { activationId: 'act-prompt-1', artifactId: 'art-001', channel: 'prompt', action: 'prompt', targetRef: 'P_001', activatedAt: '2026-06-18T00:00:00.000Z', deactivatedAt: null },
40
+ ]),
41
+ listCodeToolHookActivations: vi.fn().mockResolvedValue([
42
+ { activationId: 'act-hook-1', artifactId: 'art-002', channel: 'code_tool_hook', action: 'block', targetRef: 'rule-001', activatedAt: '2026-06-18T00:00:00.000Z', deactivatedAt: null },
43
+ ]),
44
+ listAllActivations: vi.fn().mockResolvedValue([
45
+ { activationId: 'act-prompt-1', artifactId: 'art-001', channel: 'prompt', action: 'prompt', targetRef: 'P_001', activatedAt: '2026-06-18T00:00:00.000Z', deactivatedAt: null },
46
+ { activationId: 'act-hook-1', artifactId: 'art-002', channel: 'code_tool_hook', action: 'block', targetRef: 'rule-001', activatedAt: '2026-06-18T00:00:00.000Z', deactivatedAt: null },
47
+ ]),
35
48
  };
36
49
  }),
37
50
  SqliteApprovalQueueStore: vi.fn().mockImplementation(function () {
38
51
  return {
39
52
  enqueue: vi.fn().mockResolvedValue(undefined),
53
+ edit: mockApprovalEdit,
54
+ getById: mockApprovalGetById,
55
+ };
56
+ }),
57
+ SqlitePIArtifactStore: vi.fn().mockImplementation(function () {
58
+ return {
59
+ getArtifactById: mockGetArtifactById,
40
60
  };
41
61
  }),
42
62
  ActivationDispatcher: vi.fn().mockImplementation(function () {
@@ -56,7 +76,7 @@ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
56
76
  };
57
77
  });
58
78
 
59
- import { handleRuntimeActivationDispatch } from '../../src/commands/runtime-activation.js';
79
+ import { handleRuntimeActivationDispatch, handleRuntimeActivationDeactivate, handleRuntimeActivationList, handleRuntimeActivationEdit } from '../../src/commands/runtime-activation.js';
60
80
 
61
81
  const WS = '/fake/workspace';
62
82
 
@@ -199,3 +219,535 @@ describe('handleRuntimeActivationDispatch', () => {
199
219
  expect(text).toContain('would_activate');
200
220
  });
201
221
  });
222
+
223
+ // PRI-408 Contract E: Deactivate (rollback) command tests
224
+ describe('handleRuntimeActivationDeactivate', () => {
225
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
226
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
227
+
228
+ beforeEach(() => {
229
+ vi.clearAllMocks();
230
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
231
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
232
+ });
233
+
234
+ afterEach(() => {
235
+ consoleLogSpy.mockRestore();
236
+ consoleErrorSpy.mockRestore();
237
+ process.exitCode = 0;
238
+ });
239
+
240
+ it('missing --activation-id returns structured error with nextAction (JSON)', async () => {
241
+ await handleRuntimeActivationDeactivate({
242
+ workspace: WS,
243
+ json: true,
244
+ });
245
+
246
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
247
+ expect(output.ok).toBe(false);
248
+ expect(output.reason).toBe('activation_id_required');
249
+ expect(output.nextAction).toContain('--activation-id');
250
+ expect(process.exitCode).toBe(1);
251
+ });
252
+
253
+ it('missing --activation-id prints error and nextAction (text)', async () => {
254
+ await handleRuntimeActivationDeactivate({
255
+ workspace: WS,
256
+ });
257
+
258
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('--activation-id is required'));
259
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Next action'));
260
+ expect(process.exitCode).toBe(1);
261
+ });
262
+
263
+ it('successful deactivation returns ok=true with deactivatedAt (JSON)', async () => {
264
+ await handleRuntimeActivationDeactivate({
265
+ workspace: WS,
266
+ activationId: 'act-001',
267
+ json: true,
268
+ });
269
+
270
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
271
+ expect(output.ok).toBe(true);
272
+ expect(output.activationId).toBe('act-001');
273
+ expect(output.deactivatedAt).toBeDefined();
274
+ expect(process.exitCode).toBe(0);
275
+ });
276
+
277
+ it('not-found or already-deactivated returns ok=false with reason and nextAction (JSON)', async () => {
278
+ // Override mock to return false (not found / already deactivated)
279
+ const { SqliteActivationStateStore } = await import('@principles/core/runtime-v2');
280
+ vi.mocked(SqliteActivationStateStore).mockImplementationOnce(function () {
281
+ return {
282
+ findByArtifactId: vi.fn().mockResolvedValue(null),
283
+ insert: vi.fn().mockResolvedValue(undefined),
284
+ deactivateActivation: vi.fn().mockResolvedValue(false),
285
+ listPromptActivations: vi.fn().mockResolvedValue([]),
286
+ listCodeToolHookActivations: vi.fn().mockResolvedValue([]),
287
+ listAllActivations: vi.fn().mockResolvedValue([]),
288
+ } as never;
289
+ });
290
+
291
+ await handleRuntimeActivationDeactivate({
292
+ workspace: WS,
293
+ activationId: 'act-gone',
294
+ json: true,
295
+ });
296
+
297
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
298
+ expect(output.ok).toBe(false);
299
+ expect(output.reason).toBe('not_found_or_already_deactivated');
300
+ expect(output.nextAction).toContain('Check activation ID');
301
+ expect(process.exitCode).toBe(1);
302
+ });
303
+
304
+ it('text output for success is human-readable', async () => {
305
+ await handleRuntimeActivationDeactivate({
306
+ workspace: WS,
307
+ activationId: 'act-001',
308
+ });
309
+
310
+ const text = consoleLogSpy.mock.calls.map(c => c[0]).join('\n');
311
+ expect(text).toContain('Deactivated: act-001');
312
+ expect(text).toContain('deactivatedAt:');
313
+ });
314
+ });
315
+
316
+ // PRI-408 Contract D: List activations (observability) command tests
317
+ describe('handleRuntimeActivationList', () => {
318
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
319
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
320
+
321
+ beforeEach(() => {
322
+ vi.clearAllMocks();
323
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
324
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
325
+ });
326
+
327
+ afterEach(() => {
328
+ consoleLogSpy.mockRestore();
329
+ consoleErrorSpy.mockRestore();
330
+ process.exitCode = 0;
331
+ });
332
+
333
+ it('JSON output is a single object with activations array', async () => {
334
+ await handleRuntimeActivationList({
335
+ workspace: WS,
336
+ json: true,
337
+ });
338
+
339
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
340
+ expect(Array.isArray(output.activations)).toBe(true);
341
+ expect(output.activations.length).toBe(2);
342
+ expect(output.activations[0]).toHaveProperty('activationId');
343
+ expect(output.activations[0]).toHaveProperty('channel');
344
+ });
345
+
346
+ it('channel=prompt filter calls listPromptActivations', async () => {
347
+ await handleRuntimeActivationList({
348
+ workspace: WS,
349
+ channel: 'prompt',
350
+ json: true,
351
+ });
352
+
353
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
354
+ expect(output.activations.length).toBe(1);
355
+ expect(output.activations[0].channel).toBe('prompt');
356
+ });
357
+
358
+ it('channel=code_tool_hook filter calls listCodeToolHookActivations', async () => {
359
+ await handleRuntimeActivationList({
360
+ workspace: WS,
361
+ channel: 'code_tool_hook',
362
+ json: true,
363
+ });
364
+
365
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
366
+ expect(output.activations.length).toBe(1);
367
+ expect(output.activations[0].channel).toBe('code_tool_hook');
368
+ });
369
+
370
+ it('text output is human-readable with status, id, channel', async () => {
371
+ await handleRuntimeActivationList({
372
+ workspace: WS,
373
+ });
374
+
375
+ const text = consoleLogSpy.mock.calls.map(c => c[0]).join('\n');
376
+ expect(text).toContain('[ACTIVE]');
377
+ expect(text).toContain('act-prompt-1');
378
+ expect(text).toContain('channel: prompt');
379
+ });
380
+
381
+ it('invalid channel returns structured error with nextAction (P2 #5 fix)', async () => {
382
+ await handleRuntimeActivationList({
383
+ workspace: WS,
384
+ channel: 'invalid_channel',
385
+ json: true,
386
+ });
387
+
388
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
389
+ expect(output.ok).toBe(false);
390
+ expect(output.reason).toContain('invalid_channel');
391
+ expect(output.nextAction).toContain('prompt');
392
+ expect(process.exitCode).toBe(1);
393
+ });
394
+
395
+ it('invalid channel in text mode prints error and nextAction (P2 #5 fix)', async () => {
396
+ await handleRuntimeActivationList({
397
+ workspace: WS,
398
+ channel: 'bogus',
399
+ });
400
+
401
+ const errorText = consoleErrorSpy.mock.calls.map(c => c[0]).join('\n');
402
+ expect(errorText).toContain('invalid channel');
403
+ expect(errorText).toContain('Next action');
404
+ expect(process.exitCode).toBe(1);
405
+ });
406
+ });
407
+
408
+ // P1 #2 fix: Edit pending approval command tests
409
+ describe('handleRuntimeActivationEdit', () => {
410
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
411
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
412
+
413
+ beforeEach(() => {
414
+ vi.clearAllMocks();
415
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
416
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
417
+ });
418
+
419
+ afterEach(() => {
420
+ consoleLogSpy.mockRestore();
421
+ consoleErrorSpy.mockRestore();
422
+ process.exitCode = 0;
423
+ });
424
+
425
+ it('missing --approval-id returns structured error with nextAction (JSON)', async () => {
426
+ await handleRuntimeActivationEdit({
427
+ workspace: WS,
428
+ newArtifactId: 'art-new',
429
+ editReason: 'fix typo',
430
+ json: true,
431
+ });
432
+
433
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
434
+ expect(output.ok).toBe(false);
435
+ expect(output.reason).toBe('approval_id_required');
436
+ expect(output.nextAction).toContain('--approval-id');
437
+ expect(process.exitCode).toBe(1);
438
+ });
439
+
440
+ it('missing --new-artifact-id returns structured error with nextAction (JSON)', async () => {
441
+ await handleRuntimeActivationEdit({
442
+ workspace: WS,
443
+ approvalId: 'appr-001',
444
+ editReason: 'fix typo',
445
+ json: true,
446
+ });
447
+
448
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
449
+ expect(output.ok).toBe(false);
450
+ expect(output.reason).toBe('new_artifact_id_required');
451
+ expect(output.nextAction).toContain('--new-artifact-id');
452
+ expect(process.exitCode).toBe(1);
453
+ });
454
+
455
+ it('missing --edit-reason returns structured error with nextAction (JSON)', async () => {
456
+ await handleRuntimeActivationEdit({
457
+ workspace: WS,
458
+ approvalId: 'appr-001',
459
+ newArtifactId: 'art-new',
460
+ json: true,
461
+ });
462
+
463
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
464
+ expect(output.ok).toBe(false);
465
+ expect(output.reason).toBe('edit_reason_required');
466
+ expect(output.nextAction).toContain('--edit-reason');
467
+ expect(process.exitCode).toBe(1);
468
+ });
469
+
470
+ it('successful edit returns ok with newArtifactId and previousArtifactId (JSON)', async () => {
471
+ mockApprovalGetById.mockResolvedValue({
472
+ approvalId: 'appr-001',
473
+ artifactId: 'art-old',
474
+ status: 'pending',
475
+ });
476
+ mockGetArtifactById.mockImplementation(async (id: string) => {
477
+ if (id === 'art-new') {
478
+ return makeArtifact({ artifactId: 'art-new', sourceTaskId: 'task-001' });
479
+ }
480
+ if (id === 'art-old') {
481
+ return makeArtifact({ artifactId: 'art-old', sourceTaskId: 'task-001' });
482
+ }
483
+ return null;
484
+ });
485
+ mockApprovalEdit.mockResolvedValue({
486
+ ok: true,
487
+ record: {
488
+ approvalId: 'appr-001',
489
+ artifactId: 'art-new',
490
+ previousArtifactId: 'art-old',
491
+ editedAt: '2026-06-19T00:00:00.000Z',
492
+ status: 'pending',
493
+ },
494
+ });
495
+
496
+ await handleRuntimeActivationEdit({
497
+ workspace: WS,
498
+ approvalId: 'appr-001',
499
+ newArtifactId: 'art-new',
500
+ editReason: 'fix typo in principle text',
501
+ json: true,
502
+ });
503
+
504
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
505
+ expect(output.ok).toBe(true);
506
+ expect(output.approvalId).toBe('appr-001');
507
+ expect(output.newArtifactId).toBe('art-new');
508
+ expect(output.previousArtifactId).toBe('art-old');
509
+ expect(output.editedAt).toBe('2026-06-19T00:00:00.000Z');
510
+ expect(process.exitCode).toBe(0);
511
+ });
512
+
513
+ it('not_found approval returns structured error with nextAction (JSON)', async () => {
514
+ mockApprovalGetById.mockResolvedValue(null);
515
+ mockApprovalEdit.mockResolvedValue({
516
+ ok: false,
517
+ error: 'not_found',
518
+ });
519
+
520
+ await handleRuntimeActivationEdit({
521
+ workspace: WS,
522
+ approvalId: 'appr-nonexistent',
523
+ newArtifactId: 'art-new',
524
+ editReason: 'fix typo',
525
+ json: true,
526
+ });
527
+
528
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
529
+ expect(output.ok).toBe(false);
530
+ expect(output.reason).toBe('not_found');
531
+ expect(output.nextAction).toContain('Check the approval ID');
532
+ expect(process.exitCode).toBe(1);
533
+ });
534
+
535
+ it('already_decided approval returns structured error with nextAction (JSON)', async () => {
536
+ mockApprovalGetById.mockResolvedValue({
537
+ approvalId: 'appr-001',
538
+ artifactId: 'art-old',
539
+ status: 'approved',
540
+ });
541
+ mockApprovalEdit.mockResolvedValue({
542
+ ok: false,
543
+ error: 'already_decided',
544
+ status: 'approved',
545
+ });
546
+
547
+ await handleRuntimeActivationEdit({
548
+ workspace: WS,
549
+ approvalId: 'appr-001',
550
+ newArtifactId: 'art-new',
551
+ editReason: 'fix typo',
552
+ json: true,
553
+ });
554
+
555
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
556
+ expect(output.ok).toBe(false);
557
+ expect(output.reason).toBe('already_decided');
558
+ expect(output.nextAction).toContain('already decided');
559
+ expect(output.nextAction).toContain('approved');
560
+ expect(process.exitCode).toBe(1);
561
+ });
562
+
563
+ it('edit store throw returns structured error with nextAction (JSON)', async () => {
564
+ mockApprovalGetById.mockResolvedValue({
565
+ approvalId: 'appr-001',
566
+ artifactId: 'art-old',
567
+ status: 'pending',
568
+ });
569
+ mockGetArtifactById.mockImplementation(async (id: string) => {
570
+ if (id === 'art-new') {
571
+ return makeArtifact({ artifactId: 'art-new', sourceTaskId: 'task-001' });
572
+ }
573
+ if (id === 'art-old') {
574
+ return makeArtifact({ artifactId: 'art-old', sourceTaskId: 'task-001' });
575
+ }
576
+ return null;
577
+ });
578
+ mockApprovalEdit.mockRejectedValue(new Error('SQLITE_BUSY'));
579
+
580
+ await handleRuntimeActivationEdit({
581
+ workspace: WS,
582
+ approvalId: 'appr-001',
583
+ newArtifactId: 'art-new',
584
+ editReason: 'fix typo',
585
+ json: true,
586
+ });
587
+
588
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
589
+ expect(output.ok).toBe(false);
590
+ expect(output.reason).toContain('edit_failed');
591
+ expect(output.reason).toContain('SQLITE_BUSY');
592
+ expect(output.nextAction).toContain('DB integrity');
593
+ expect(process.exitCode).toBe(1);
594
+ });
595
+
596
+ it('text output is human-readable with next action hint', async () => {
597
+ mockApprovalGetById.mockResolvedValue({
598
+ approvalId: 'appr-001',
599
+ artifactId: 'art-old',
600
+ status: 'pending',
601
+ });
602
+ mockGetArtifactById.mockImplementation(async (id: string) => {
603
+ if (id === 'art-new') {
604
+ return makeArtifact({ artifactId: 'art-new', sourceTaskId: 'task-001' });
605
+ }
606
+ if (id === 'art-old') {
607
+ return makeArtifact({ artifactId: 'art-old', sourceTaskId: 'task-001' });
608
+ }
609
+ return null;
610
+ });
611
+ mockApprovalEdit.mockResolvedValue({
612
+ ok: true,
613
+ record: {
614
+ approvalId: 'appr-001',
615
+ artifactId: 'art-new',
616
+ previousArtifactId: 'art-old',
617
+ editedAt: '2026-06-19T00:00:00.000Z',
618
+ status: 'pending',
619
+ },
620
+ });
621
+
622
+ await handleRuntimeActivationEdit({
623
+ workspace: WS,
624
+ approvalId: 'appr-001',
625
+ newArtifactId: 'art-new',
626
+ editReason: 'fix typo',
627
+ });
628
+
629
+ const text = consoleLogSpy.mock.calls.map(c => c[0]).join('\n');
630
+ expect(text).toContain('Approval edited: appr-001');
631
+ expect(text).toContain('newArtifactId: art-new');
632
+ expect(text).toContain('previousArtifactId: art-old');
633
+ expect(text).toContain('Next action');
634
+ });
635
+
636
+ it('artifact_not_found returns structured error with nextAction (JSON)', async () => {
637
+ mockApprovalGetById.mockResolvedValue({
638
+ approvalId: 'appr-001',
639
+ artifactId: 'art-old',
640
+ status: 'pending',
641
+ });
642
+ mockGetArtifactById.mockResolvedValue(null);
643
+
644
+ await handleRuntimeActivationEdit({
645
+ workspace: WS,
646
+ approvalId: 'appr-001',
647
+ newArtifactId: 'art-nonexistent',
648
+ editReason: 'fix typo',
649
+ json: true,
650
+ });
651
+
652
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
653
+ expect(output.ok).toBe(false);
654
+ expect(output.reason).toBe('artifact_not_found');
655
+ expect(output.nextAction).toContain('does not exist');
656
+ expect(process.exitCode).toBe(1);
657
+ });
658
+
659
+ it('artifact_not_validated returns structured error with nextAction (JSON)', async () => {
660
+ mockApprovalGetById.mockResolvedValue({
661
+ approvalId: 'appr-001',
662
+ artifactId: 'art-old',
663
+ status: 'pending',
664
+ });
665
+ mockGetArtifactById.mockImplementation(async (id: string) => {
666
+ if (id === 'art-new') {
667
+ return { artifactId: 'art-new', validationStatus: 'pending', sourceTaskId: 'task-001' };
668
+ }
669
+ return null;
670
+ });
671
+
672
+ await handleRuntimeActivationEdit({
673
+ workspace: WS,
674
+ approvalId: 'appr-001',
675
+ newArtifactId: 'art-new',
676
+ editReason: 'fix typo',
677
+ json: true,
678
+ });
679
+
680
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
681
+ expect(output.ok).toBe(false);
682
+ expect(output.reason).toContain('artifact_not_validated');
683
+ expect(output.nextAction).toContain('production gate');
684
+ expect(process.exitCode).toBe(1);
685
+ });
686
+
687
+ it('artifact_lineage_mismatch returns structured error with nextAction (JSON)', async () => {
688
+ mockApprovalGetById.mockResolvedValue({
689
+ approvalId: 'appr-001',
690
+ artifactId: 'art-old',
691
+ status: 'pending',
692
+ });
693
+ mockGetArtifactById.mockImplementation(async (id: string) => {
694
+ if (id === 'art-new') {
695
+ return { artifactId: 'art-new', validationStatus: 'validated', sourceTaskId: 'task-002', lineageArtifactIds: [] };
696
+ }
697
+ if (id === 'art-old') {
698
+ return { artifactId: 'art-old', validationStatus: 'validated', sourceTaskId: 'task-001', sourcePrincipleId: 'principle-old', lineageArtifactIds: [] };
699
+ }
700
+ return null;
701
+ });
702
+
703
+ await handleRuntimeActivationEdit({
704
+ workspace: WS,
705
+ approvalId: 'appr-001',
706
+ newArtifactId: 'art-new',
707
+ editReason: 'fix typo',
708
+ json: true,
709
+ });
710
+
711
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
712
+ expect(output.ok).toBe(false);
713
+ expect(output.reason).toBe('artifact_lineage_mismatch');
714
+ expect(output.nextAction).toContain('must reference art-old');
715
+ expect(process.exitCode).toBe(1);
716
+ });
717
+
718
+ it('allows a validated revision from a new task when lineage references the approved artifact', async () => {
719
+ mockApprovalGetById.mockResolvedValue({ approvalId: 'appr-001', artifactId: 'art-old', status: 'pending' });
720
+ mockGetArtifactById.mockImplementation(async (id: string) => {
721
+ if (id === 'art-new') {
722
+ return {
723
+ artifactId: 'art-new', validationStatus: 'validated', sourceTaskId: 'owner-edit-task',
724
+ sourcePrincipleId: 'principle-old', lineageArtifactIds: ['art-old'],
725
+ };
726
+ }
727
+ if (id === 'art-old') {
728
+ return {
729
+ artifactId: 'art-old', validationStatus: 'validated', sourceTaskId: 'generation-task',
730
+ sourcePrincipleId: 'principle-old', lineageArtifactIds: [],
731
+ };
732
+ }
733
+ return null;
734
+ });
735
+ mockApprovalEdit.mockResolvedValue({
736
+ ok: true,
737
+ record: { approvalId: 'appr-001', artifactId: 'art-new', previousArtifactId: 'art-old', status: 'pending' },
738
+ });
739
+
740
+ await handleRuntimeActivationEdit({
741
+ workspace: WS,
742
+ approvalId: 'appr-001',
743
+ newArtifactId: 'art-new',
744
+ editReason: 'owner revision',
745
+ json: true,
746
+ });
747
+
748
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
749
+ expect(output.ok).toBe(true);
750
+ expect(mockApprovalEdit).toHaveBeenCalledOnce();
751
+ });
752
+
753
+ });