principles-disciple 1.41.0 → 1.43.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.
Files changed (95) hide show
  1. package/.planning/codebase/ARCHITECTURE.md +157 -0
  2. package/.planning/codebase/CONCERNS.md +145 -0
  3. package/.planning/codebase/CONVENTIONS.md +148 -0
  4. package/.planning/codebase/INTEGRATIONS.md +81 -0
  5. package/.planning/codebase/STACK.md +87 -0
  6. package/.planning/codebase/STRUCTURE.md +193 -0
  7. package/.planning/codebase/TESTING.md +243 -0
  8. package/openclaw.plugin.json +1 -1
  9. package/package.json +1 -1
  10. package/src/commands/archive-impl.ts +5 -3
  11. package/src/commands/context.ts +1 -0
  12. package/src/commands/disable-impl.ts +1 -1
  13. package/src/commands/evolution-status.ts +2 -2
  14. package/src/commands/pain.ts +12 -5
  15. package/src/commands/principle-rollback.ts +1 -1
  16. package/src/commands/promote-impl.ts +13 -7
  17. package/src/commands/rollback.ts +10 -4
  18. package/src/commands/samples.ts +1 -1
  19. package/src/commands/thinking-os.ts +1 -0
  20. package/src/commands/workflow-debug.ts +1 -1
  21. package/src/core/config.ts +1 -0
  22. package/src/core/dictionary.ts +1 -0
  23. package/src/core/event-log.ts +8 -6
  24. package/src/core/evolution-types.ts +33 -1
  25. package/src/core/external-training-contract.ts +1 -1
  26. package/src/core/merge-gate-audit.ts +3 -3
  27. package/src/core/nocturnal-arbiter.ts +1 -1
  28. package/src/core/nocturnal-compliance.ts +21 -21
  29. package/src/core/nocturnal-executability.ts +1 -1
  30. package/src/core/nocturnal-reasoning-deriver.ts +4 -4
  31. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  32. package/src/core/nocturnal-snapshot-contract.ts +1 -1
  33. package/src/core/pain-context-extractor.ts +2 -2
  34. package/src/core/path-resolver.ts +1 -0
  35. package/src/core/pd-task-reconciler.ts +1 -0
  36. package/src/core/pd-task-service.ts +1 -1
  37. package/src/core/pd-task-store.ts +1 -0
  38. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  39. package/src/core/principle-internalization/principle-lifecycle-service.ts +1 -1
  40. package/src/core/principle-training-state.ts +2 -2
  41. package/src/core/principle-tree-migration.ts +1 -1
  42. package/src/core/replay-engine.ts +1 -0
  43. package/src/core/risk-calculator.ts +2 -1
  44. package/src/core/rule-host.ts +1 -1
  45. package/src/core/session-tracker.ts +1 -0
  46. package/src/core/shadow-observation-registry.ts +1 -1
  47. package/src/core/thinking-models.ts +1 -1
  48. package/src/core/thinking-os-parser.ts +1 -1
  49. package/src/core/trajectory.ts +2 -0
  50. package/src/hooks/bash-risk.ts +2 -2
  51. package/src/hooks/edit-verification.ts +3 -3
  52. package/src/hooks/gate.ts +8 -8
  53. package/src/hooks/gfi-gate.ts +2 -2
  54. package/src/hooks/lifecycle-routing.ts +1 -1
  55. package/src/hooks/message-sanitize.ts +18 -5
  56. package/src/hooks/pain.ts +2 -2
  57. package/src/hooks/progressive-trust-gate.ts +3 -3
  58. package/src/hooks/prompt.ts +17 -4
  59. package/src/hooks/subagent.ts +2 -3
  60. package/src/hooks/thinking-checkpoint.ts +1 -1
  61. package/src/http/principles-console-route.ts +21 -4
  62. package/src/service/central-database.ts +3 -2
  63. package/src/service/central-health-service.ts +2 -1
  64. package/src/service/central-overview-service.ts +3 -2
  65. package/src/service/control-ui-query-service.ts +2 -2
  66. package/src/service/event-log-auditor.ts +2 -2
  67. package/src/service/evolution-query-service.ts +1 -1
  68. package/src/service/evolution-worker.ts +96 -370
  69. package/src/service/health-query-service.ts +11 -10
  70. package/src/service/monitoring-query-service.ts +4 -4
  71. package/src/service/nocturnal-target-selector.ts +2 -2
  72. package/src/service/queue-io.ts +375 -0
  73. package/src/service/queue-migration.ts +122 -0
  74. package/src/service/runtime-summary-service.ts +1 -1
  75. package/src/service/sleep-cycle.ts +157 -0
  76. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +1 -0
  77. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  78. package/src/service/subagent-workflow/subagent-error-utils.ts +1 -1
  79. package/src/service/subagent-workflow/workflow-store.ts +3 -2
  80. package/src/service/workflow-watchdog.ts +168 -0
  81. package/src/tools/critique-prompt.ts +1 -1
  82. package/src/tools/deep-reflect.ts +22 -11
  83. package/src/tools/model-index.ts +1 -1
  84. package/src/types/event-payload.ts +80 -0
  85. package/src/types/queue.ts +70 -0
  86. package/src/utils/file-lock.ts +2 -2
  87. package/src/utils/io.ts +11 -3
  88. package/tests/core/evolution-migration.test.ts +325 -1
  89. package/tests/core/queue-purge.test.ts +337 -0
  90. package/tests/fixtures/legacy-queue-v1.json +74 -0
  91. package/tests/queue/async-lock.test.ts +200 -0
  92. package/tests/service/evolution-worker.queue.test.ts +296 -0
  93. package/tests/service/queue-io.test.ts +229 -0
  94. package/tests/service/queue-migration.test.ts +147 -0
  95. package/tests/service/workflow-watchdog.test.ts +372 -0
@@ -0,0 +1,74 @@
1
+ [
2
+ {
3
+ "id": "legacy-pain-001",
4
+ "task": "Diagnose tool_failure pain from write operation",
5
+ "score": 78,
6
+ "source": "tool_failure",
7
+ "reason": "Tool write failed on test.md. Error: EACCES permission denied",
8
+ "timestamp": "2026-04-10T08:30:00.000Z",
9
+ "enqueued_at": "2026-04-10T08:30:05.000Z",
10
+ "started_at": null,
11
+ "completed_at": null,
12
+ "assigned_session_key": null,
13
+ "trigger_text_preview": "Tool write failed on test.md",
14
+ "status": "pending",
15
+ "resolution": null,
16
+ "session_id": null,
17
+ "agent_id": null,
18
+ "traceId": "trace-legacy-001"
19
+ },
20
+ {
21
+ "id": "legacy-pain-002",
22
+ "task": "Diagnose runtime_unavailable pain from agent session",
23
+ "score": 65,
24
+ "source": "runtime_unavailable",
25
+ "reason": "Agent session timed out after 300s inactivity window",
26
+ "timestamp": "2026-04-11T14:22:00.000Z",
27
+ "enqueued_at": "2026-04-11T14:22:10.000Z",
28
+ "started_at": "2026-04-11T14:25:00.000Z",
29
+ "completed_at": null,
30
+ "assigned_session_key": "session-key-abc123",
31
+ "trigger_text_preview": "Agent session timed out",
32
+ "status": "in_progress",
33
+ "resolution": null,
34
+ "session_id": "session-abc123",
35
+ "agent_id": "main",
36
+ "traceId": "trace-legacy-002"
37
+ },
38
+ {
39
+ "id": "legacy-pain-003",
40
+ "task": "Diagnose gate_block pain from scoring validation",
41
+ "score": 90,
42
+ "source": "gate_block",
43
+ "reason": "Principle score 0.12 below threshold 0.70 for late_marker_no_principle",
44
+ "timestamp": "2026-04-12T09:15:00.000Z",
45
+ "enqueued_at": "2026-04-12T09:15:30.000Z",
46
+ "started_at": "2026-04-12T09:16:00.000Z",
47
+ "completed_at": "2026-04-12T09:20:45.000Z",
48
+ "assigned_session_key": "session-key-def456",
49
+ "trigger_text_preview": "Principle score below threshold",
50
+ "status": "completed",
51
+ "resolution": "late_marker_no_principle",
52
+ "session_id": "session-def456",
53
+ "agent_id": "main",
54
+ "traceId": "trace-legacy-003"
55
+ },
56
+ {
57
+ "id": "legacy-pain-004",
58
+ "task": "Diagnose thin_violation pain from rule scoring",
59
+ "score": 55,
60
+ "source": "thin_violation",
61
+ "reason": "Rule 'no-bare-throw' triggered but violation was benign; investigate scoring bias",
62
+ "timestamp": "2026-04-13T16:45:00.000Z",
63
+ "enqueued_at": "2026-04-13T16:45:15.000Z",
64
+ "started_at": "2026-04-13T16:46:00.000Z",
65
+ "completed_at": "2026-04-13T16:50:30.000Z",
66
+ "assigned_session_key": "session-key-ghi789",
67
+ "trigger_text_preview": "Rule scoring bias investigation",
68
+ "status": "failed",
69
+ "resolution": "failed_max_retries",
70
+ "session_id": "session-ghi789",
71
+ "agent_id": "main",
72
+ "traceId": "trace-legacy-004"
73
+ }
74
+ ]
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Unit tests for asyncLockQueues concurrency with Promise.all race detection.
3
+ * Tests file-level async lock serialization and Map state cleanup per D-05.
4
+ * Uses vi.useFakeTimers() in beforeEach/afterEach per D-10,
5
+ * but concurrency tests run with real timers to avoid Promise.all + fake timer issues.
6
+ * Uses os.tmpdir() per D-11.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import * as fs from 'fs';
11
+ import * as os from 'os';
12
+ import * as path from 'path';
13
+ import { withAsyncLock, asyncLockQueues } from '../../src/utils/file-lock.js';
14
+
15
+ describe('asyncLockQueues', () => {
16
+ let tempDir: string;
17
+
18
+ beforeEach(() => {
19
+ // D-10: Use fake timers in beforeEach (afterEach restores real timers)
20
+ vi.useFakeTimers();
21
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-async-lock-test-'));
22
+ // D-05: clear Map state between tests
23
+ asyncLockQueues.clear();
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.useRealTimers();
28
+ fs.rmSync(tempDir, { recursive: true, force: true });
29
+ });
30
+
31
+ it('serializes concurrent operations on same file (Promise.all race detection)', async () => {
32
+ const filePath = path.join(tempDir, 'serialized-test.json');
33
+ fs.writeFileSync(filePath, '{}', 'utf8');
34
+
35
+ const results: number[] = [];
36
+
37
+ // Use real timers for this test (fake timers + Promise.all + setTimeout don't mix well)
38
+ vi.useRealTimers();
39
+
40
+ const promises = [
41
+ withAsyncLock(filePath, async () => {
42
+ results.push(1);
43
+ await new Promise(r => setTimeout(r, 50));
44
+ results.push(2);
45
+ }),
46
+ withAsyncLock(filePath, async () => {
47
+ results.push(3);
48
+ await new Promise(r => setTimeout(r, 50));
49
+ results.push(4);
50
+ }),
51
+ ];
52
+
53
+ await Promise.all(promises);
54
+
55
+ // Both operations completed
56
+ expect(results).toHaveLength(4);
57
+
58
+ // Non-interleaved: first operation completes before second starts
59
+ // [1, 2, 3, 4] means first ran fully, then second ran fully
60
+ // [3, 4, 1, 2] means second ran fully, then first ran fully
61
+ // [1, 3, 2, 4] would mean interleaving (bad) — but lock prevents this
62
+ const isNonInterleaved =
63
+ (results[0] === 1 && results[1] === 2) ||
64
+ (results[0] === 3 && results[1] === 4);
65
+
66
+ expect(isNonInterleaved).toBe(true);
67
+
68
+ // Restore fake timers for afterEach cleanup
69
+ vi.useFakeTimers();
70
+ });
71
+
72
+ it('allows concurrent operations on different files', async () => {
73
+ const fileA = path.join(tempDir, 'file-a.json');
74
+ const fileB = path.join(tempDir, 'file-b.json');
75
+ fs.writeFileSync(fileA, '{}', 'utf8');
76
+ fs.writeFileSync(fileB, '{}', 'utf8');
77
+
78
+ const results: string[] = [];
79
+
80
+ // Use real timers for this test
81
+ vi.useRealTimers();
82
+
83
+ const promiseA = withAsyncLock(fileA, async () => {
84
+ results.push('A-start');
85
+ await new Promise(r => setTimeout(r, 20));
86
+ results.push('A-end');
87
+ });
88
+
89
+ const promiseB = withAsyncLock(fileB, async () => {
90
+ results.push('B-start');
91
+ await new Promise(r => setTimeout(r, 20));
92
+ results.push('B-end');
93
+ });
94
+
95
+ await Promise.all([promiseA, promiseB]);
96
+
97
+ // Both operations completed
98
+ expect(results).toHaveLength(4);
99
+ // Operations on different files should interleave (both run concurrently)
100
+ expect(results).toContain('A-start');
101
+ expect(results).toContain('A-end');
102
+ expect(results).toContain('B-start');
103
+ expect(results).toContain('B-end');
104
+
105
+ // Restore fake timers for afterEach cleanup
106
+ vi.useFakeTimers();
107
+ });
108
+
109
+ it('clears Map state between tests (beforeEach clearing)', async () => {
110
+ // Verify Map is empty at start of test (beforeEach cleared it)
111
+ expect(asyncLockQueues.size).toBe(0);
112
+
113
+ const filePath = path.join(tempDir, 'map-state-test.json');
114
+ fs.writeFileSync(filePath, '{}', 'utf8');
115
+
116
+ // Use real timers
117
+ vi.useRealTimers();
118
+
119
+ // Add entries to the Map via withAsyncLock (starts operations but doesn't wait)
120
+ const p1 = withAsyncLock(filePath, async () => {
121
+ await new Promise(r => setTimeout(r, 100));
122
+ });
123
+
124
+ // Wait a bit for the operation to start
125
+ await new Promise(r => setTimeout(r, 10));
126
+
127
+ // Map should have entries (lock for filePath is queued)
128
+ expect(asyncLockQueues.size).toBeGreaterThanOrEqual(1);
129
+
130
+ // Wait for operation to complete to avoid affecting other tests
131
+ await p1;
132
+
133
+ // Restore fake timers for afterEach cleanup
134
+ vi.useFakeTimers();
135
+ });
136
+
137
+ it('releases lock after function throws', async () => {
138
+ const filePath = path.join(tempDir, 'error-test.json');
139
+ fs.writeFileSync(filePath, '{}', 'utf8');
140
+
141
+ // Use real timers
142
+ vi.useRealTimers();
143
+
144
+ // First call throws
145
+ await expect(
146
+ withAsyncLock(filePath, async () => {
147
+ throw new Error('intentional failure');
148
+ })
149
+ ).rejects.toThrow('intentional failure');
150
+
151
+ // Second call should succeed (lock was released)
152
+ const result = await withAsyncLock(filePath, async () => 'success-after-error');
153
+
154
+ expect(result).toBe('success-after-error');
155
+
156
+ // Restore fake timers for afterEach cleanup
157
+ vi.useFakeTimers();
158
+ });
159
+
160
+ it('returns correct value from withAsyncLock', async () => {
161
+ const filePath = path.join(tempDir, 'return-value-test.json');
162
+ fs.writeFileSync(filePath, '{}', 'utf8');
163
+
164
+ // Use real timers
165
+ vi.useRealTimers();
166
+
167
+ const result = await withAsyncLock(filePath, async () => {
168
+ return { success: true, value: 42 };
169
+ });
170
+
171
+ expect(result).toEqual({ success: true, value: 42 });
172
+
173
+ // Restore fake timers for afterEach cleanup
174
+ vi.useFakeTimers();
175
+ });
176
+
177
+ it('handles multiple sequential operations on same file', async () => {
178
+ const filePath = path.join(tempDir, 'sequential-test.json');
179
+ fs.writeFileSync(filePath, '{}', 'utf8');
180
+
181
+ // Use real timers
182
+ vi.useRealTimers();
183
+
184
+ const results: number[] = [];
185
+
186
+ for (let i = 0; i < 3; i++) {
187
+ const idx = i;
188
+ await withAsyncLock(filePath, async () => {
189
+ results.push(idx);
190
+ await new Promise(r => setTimeout(r, 10));
191
+ });
192
+ }
193
+
194
+ // All three sequential operations completed
195
+ expect(results).toEqual([0, 1, 2]);
196
+
197
+ // Restore fake timers for afterEach cleanup
198
+ vi.useFakeTimers();
199
+ });
200
+ });
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Unit tests for loadEvolutionQueue queue loading and migration.
3
+ * Uses vi.useFakeTimers() per D-10.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+ import { loadEvolutionQueue, validateQueueEventPayload } from '../../src/service/evolution-worker.js';
11
+
12
+ describe('loadEvolutionQueue', () => {
13
+ let tempDir: string;
14
+
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-queue-test-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ fs.rmSync(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it('loads and migrates legacy queue from file', () => {
26
+ // Write legacy v1 format (no taskKind, no priority)
27
+ const legacyQueue = [
28
+ {
29
+ id: 'legacy-001',
30
+ task: 'Diagnose tool_failure pain',
31
+ score: 78,
32
+ source: 'tool_failure',
33
+ reason: 'Tool write failed',
34
+ timestamp: '2026-04-10T08:30:00.000Z',
35
+ enqueued_at: '2026-04-10T08:30:05.000Z',
36
+ started_at: null,
37
+ completed_at: null,
38
+ assigned_session_key: null,
39
+ trigger_text_preview: 'Tool write failed',
40
+ status: 'pending',
41
+ resolution: null,
42
+ session_id: null,
43
+ agent_id: null,
44
+ traceId: 'trace-001',
45
+ },
46
+ ];
47
+ const queuePath = path.join(tempDir, 'evolution_queue.json');
48
+ fs.writeFileSync(queuePath, JSON.stringify(legacyQueue), 'utf8');
49
+
50
+ const result = loadEvolutionQueue(queuePath);
51
+
52
+ expect(result).toHaveLength(1);
53
+ expect(result[0].id).toBe('legacy-001');
54
+ expect(result[0].taskKind).toBe('pain_diagnosis'); // DEFAULT_TASK_KIND
55
+ expect(result[0].priority).toBe('medium'); // DEFAULT_PRIORITY
56
+ expect(result[0].retryCount).toBe(0);
57
+ expect(result[0].maxRetries).toBe(3);
58
+ });
59
+
60
+ it('loads empty array when file does not exist', () => {
61
+ const nonExistentPath = path.join(tempDir, 'does-not-exist.json');
62
+ const result = loadEvolutionQueue(nonExistentPath);
63
+
64
+ expect(result).toEqual([]);
65
+ });
66
+
67
+ it('loads existing V2 queue unchanged', () => {
68
+ // Write V2 format with taskKind and priority
69
+ const v2Queue = [
70
+ {
71
+ id: 'v2-001',
72
+ taskKind: 'sleep_reflection',
73
+ priority: 'high',
74
+ score: 55,
75
+ source: 'nocturnal',
76
+ reason: 'Idle workspace',
77
+ timestamp: '2026-04-13T00:00:00.000Z',
78
+ enqueued_at: '2026-04-13T00:00:05.000Z',
79
+ started_at: null,
80
+ completed_at: null,
81
+ assigned_session_key: null,
82
+ trigger_text_preview: 'Idle workspace detected',
83
+ status: 'pending',
84
+ resolution: undefined,
85
+ session_id: null,
86
+ agent_id: null,
87
+ traceId: 'trace-v2-001',
88
+ retryCount: 0,
89
+ maxRetries: 1,
90
+ lastError: undefined,
91
+ resultRef: undefined,
92
+ },
93
+ ];
94
+ const queuePath = path.join(tempDir, 'evolution_queue_v2.json');
95
+ fs.writeFileSync(queuePath, JSON.stringify(v2Queue), 'utf8');
96
+
97
+ const result = loadEvolutionQueue(queuePath);
98
+
99
+ expect(result).toHaveLength(1);
100
+ expect(result[0].id).toBe('v2-001');
101
+ expect(result[0].taskKind).toBe('sleep_reflection'); // unchanged
102
+ expect(result[0].priority).toBe('high'); // unchanged
103
+ expect(result[0].maxRetries).toBe(1); // unchanged
104
+ });
105
+
106
+ it('respects timestamp ordering in loaded queue', () => {
107
+ const orderedQueue = [
108
+ {
109
+ id: 'first',
110
+ taskKind: 'pain_diagnosis',
111
+ priority: 'low',
112
+ score: 30,
113
+ source: 'tool_failure',
114
+ reason: 'First task',
115
+ timestamp: '2026-04-10T08:00:00.000Z',
116
+ enqueued_at: '2026-04-10T08:00:00.000Z',
117
+ started_at: null,
118
+ completed_at: null,
119
+ assigned_session_key: null,
120
+ trigger_text_preview: 'First',
121
+ status: 'pending',
122
+ resolution: undefined,
123
+ session_id: null,
124
+ agent_id: null,
125
+ traceId: 'trace-first',
126
+ retryCount: 0,
127
+ maxRetries: 3,
128
+ lastError: undefined,
129
+ resultRef: undefined,
130
+ },
131
+ {
132
+ id: 'second',
133
+ taskKind: 'pain_diagnosis',
134
+ priority: 'medium',
135
+ score: 60,
136
+ source: 'tool_failure',
137
+ reason: 'Second task',
138
+ timestamp: '2026-04-11T08:00:00.000Z',
139
+ enqueued_at: '2026-04-11T08:00:00.000Z',
140
+ started_at: null,
141
+ completed_at: null,
142
+ assigned_session_key: null,
143
+ trigger_text_preview: 'Second',
144
+ status: 'pending',
145
+ resolution: undefined,
146
+ session_id: null,
147
+ agent_id: null,
148
+ traceId: 'trace-second',
149
+ retryCount: 0,
150
+ maxRetries: 3,
151
+ lastError: undefined,
152
+ resultRef: undefined,
153
+ },
154
+ {
155
+ id: 'third',
156
+ taskKind: 'pain_diagnosis',
157
+ priority: 'high',
158
+ score: 90,
159
+ source: 'tool_failure',
160
+ reason: 'Third task',
161
+ timestamp: '2026-04-12T08:00:00.000Z',
162
+ enqueued_at: '2026-04-12T08:00:00.000Z',
163
+ started_at: null,
164
+ completed_at: null,
165
+ assigned_session_key: null,
166
+ trigger_text_preview: 'Third',
167
+ status: 'pending',
168
+ resolution: undefined,
169
+ session_id: null,
170
+ agent_id: null,
171
+ traceId: 'trace-third',
172
+ retryCount: 0,
173
+ maxRetries: 3,
174
+ lastError: undefined,
175
+ resultRef: undefined,
176
+ },
177
+ ];
178
+ const queuePath = path.join(tempDir, 'ordered_queue.json');
179
+ fs.writeFileSync(queuePath, JSON.stringify(orderedQueue), 'utf8');
180
+
181
+ const result = loadEvolutionQueue(queuePath);
182
+
183
+ expect(result[0].id).toBe('first');
184
+ expect(result[1].id).toBe('second');
185
+ expect(result[2].id).toBe('third');
186
+ });
187
+
188
+ it('handles file with trailing newline', () => {
189
+ const queueWithNewline = [
190
+ {
191
+ id: 'newline-test',
192
+ taskKind: 'pain_diagnosis',
193
+ priority: 'medium',
194
+ score: 50,
195
+ source: 'tool_failure',
196
+ reason: 'Trailing newline test',
197
+ timestamp: '2026-04-10T10:00:00.000Z',
198
+ enqueued_at: '2026-04-10T10:00:00.000Z',
199
+ started_at: null,
200
+ completed_at: null,
201
+ assigned_session_key: null,
202
+ trigger_text_preview: 'Trailing newline',
203
+ status: 'pending',
204
+ resolution: undefined,
205
+ session_id: null,
206
+ agent_id: null,
207
+ traceId: 'trace-newline',
208
+ retryCount: 0,
209
+ maxRetries: 3,
210
+ lastError: undefined,
211
+ resultRef: undefined,
212
+ },
213
+ ];
214
+ const queuePath = path.join(tempDir, 'newline_queue.json');
215
+ fs.writeFileSync(queuePath, JSON.stringify(queueWithNewline) + '\n', 'utf8');
216
+
217
+ const result = loadEvolutionQueue(queuePath);
218
+
219
+ expect(result).toHaveLength(1);
220
+ expect(result[0].id).toBe('newline-test');
221
+ });
222
+
223
+ it('round-trip: load returns same data that was written', () => {
224
+ // Write a V2-format queue, load it, verify data integrity
225
+ const originalQueue = [
226
+ {
227
+ id: 'roundtrip-001',
228
+ taskKind: 'sleep_reflection',
229
+ priority: 'high',
230
+ score: 88,
231
+ source: 'nocturnal',
232
+ reason: 'Round-trip test',
233
+ timestamp: '2026-04-14T02:00:00.000Z',
234
+ enqueued_at: '2026-04-14T02:00:00.000Z',
235
+ started_at: null,
236
+ completed_at: null,
237
+ assigned_session_key: null,
238
+ trigger_text_preview: 'Round-trip',
239
+ status: 'pending',
240
+ resolution: undefined,
241
+ session_id: null,
242
+ agent_id: null,
243
+ traceId: 'trace-roundtrip',
244
+ retryCount: 0,
245
+ maxRetries: 1,
246
+ lastError: undefined,
247
+ resultRef: undefined,
248
+ },
249
+ ];
250
+ const queuePath = path.join(tempDir, 'roundtrip_queue.json');
251
+ fs.writeFileSync(queuePath, JSON.stringify(originalQueue), 'utf8');
252
+
253
+ const loaded = loadEvolutionQueue(queuePath);
254
+
255
+ expect(loaded).toHaveLength(1);
256
+ expect(loaded[0].id).toBe(originalQueue[0].id);
257
+ expect(loaded[0].taskKind).toBe(originalQueue[0].taskKind);
258
+ expect(loaded[0].priority).toBe(originalQueue[0].priority);
259
+ expect(loaded[0].score).toBe(originalQueue[0].score);
260
+ expect(loaded[0].source).toBe(originalQueue[0].source);
261
+ expect(loaded[0].status).toBe(originalQueue[0].status);
262
+ });
263
+ });
264
+
265
+ describe('validateQueueEventPayload', () => {
266
+ it('returns empty object for null/undefined', () => {
267
+ expect(validateQueueEventPayload(null)).toEqual({});
268
+ expect(validateQueueEventPayload(undefined)).toEqual({});
269
+ });
270
+
271
+ it('throws for non-string input', () => {
272
+ expect(() => (validateQueueEventPayload as any)(123)).toThrow('must be a string');
273
+ expect(() => (validateQueueEventPayload as any)({})).toThrow('must be a string');
274
+ });
275
+
276
+ it('throws for JSON that is not an object', () => {
277
+ // Primitive JSON values pass typeof check but fail the object/null guard
278
+ expect(() => validateQueueEventPayload('"string"')).toThrow('must be a JSON object');
279
+ // Arrays pass typeof === 'object' check so they reach required fields check first
280
+ expect(() => validateQueueEventPayload('[1,2,3]')).toThrow('missing required fields');
281
+ });
282
+
283
+ it('throws for object missing required fields', () => {
284
+ expect(() => validateQueueEventPayload('{"type":"x"}')).toThrow('missing required fields');
285
+ expect(() => validateQueueEventPayload('{"workspaceId":"x"}')).toThrow('missing required fields');
286
+ });
287
+
288
+ it('returns parsed object for valid payload', () => {
289
+ const valid = '{"type":"test","workspaceId":"ws-001"}';
290
+ expect(validateQueueEventPayload(valid)).toEqual({ type: 'test', workspaceId: 'ws-001' });
291
+ });
292
+
293
+ it('throws wrapped SyntaxError for invalid JSON', () => {
294
+ expect(() => validateQueueEventPayload('not json')).toThrow('Invalid JSON');
295
+ });
296
+ });