principles-disciple 1.40.0 → 1.42.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 (52) 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/esbuild.config.js +32 -3
  9. package/openclaw.plugin.json +1 -1
  10. package/package.json +2 -1
  11. package/scripts/compile-principles.mjs +94 -0
  12. package/scripts/sync-plugin.mjs +96 -281
  13. package/src/commands/pain.ts +12 -5
  14. package/src/commands/promote-impl.ts +13 -7
  15. package/src/commands/rollback.ts +10 -3
  16. package/src/core/event-log.ts +8 -6
  17. package/src/core/evolution-types.ts +33 -1
  18. package/src/core/principle-compiler/code-validator.ts +120 -0
  19. package/src/core/principle-compiler/compiler.ts +242 -0
  20. package/src/core/principle-compiler/index.ts +10 -0
  21. package/src/core/principle-compiler/ledger-registrar.ts +107 -0
  22. package/src/core/principle-compiler/template-generator.ts +108 -0
  23. package/src/core/reflection/reflection-context.ts +228 -0
  24. package/src/hooks/message-sanitize.ts +18 -5
  25. package/src/hooks/prompt.ts +15 -4
  26. package/src/hooks/subagent.ts +2 -3
  27. package/src/http/principles-console-route.ts +21 -4
  28. package/src/service/evolution-worker.ts +89 -365
  29. package/src/service/queue-io.ts +375 -0
  30. package/src/service/queue-migration.ts +122 -0
  31. package/src/service/sleep-cycle.ts +157 -0
  32. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  33. package/src/service/workflow-watchdog.ts +168 -0
  34. package/src/tools/deep-reflect.ts +22 -11
  35. package/src/types/event-payload.ts +80 -0
  36. package/src/types/queue.ts +70 -0
  37. package/src/utils/file-lock.ts +2 -2
  38. package/src/utils/io.ts +11 -3
  39. package/tests/core/code-validator.test.ts +197 -0
  40. package/tests/core/evolution-migration.test.ts +325 -1
  41. package/tests/core/ledger-registrar.test.ts +232 -0
  42. package/tests/core/principle-compiler.test.ts +348 -0
  43. package/tests/core/queue-purge.test.ts +337 -0
  44. package/tests/core/reflection-context.test.ts +356 -0
  45. package/tests/core/template-generator.test.ts +101 -0
  46. package/tests/fixtures/legacy-queue-v1.json +74 -0
  47. package/tests/integration/principle-compiler-e2e.test.ts +335 -0
  48. package/tests/queue/async-lock.test.ts +200 -0
  49. package/tests/service/evolution-worker.queue.test.ts +296 -0
  50. package/tests/service/queue-io.test.ts +229 -0
  51. package/tests/service/queue-migration.test.ts +147 -0
  52. package/tests/service/workflow-watchdog.test.ts +372 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Workflow Watchdog - Extracted from evolution-worker.ts (lines 79-223)
3
+ *
4
+ * Detects stale/orphaned workflows, invalid results, and cleanup failures.
5
+ * Runs every heartbeat cycle, catching bugs like:
6
+ * #185 — orphaned active workflows
7
+ * #181 — structurally invalid results (all zeros)
8
+ * #180/#183 — expired workflows not swept
9
+ * #182 — unhandled rejections leaving workflows in limbo
10
+ *
11
+ * BUG-01: isExpectedSubagentError guard prevents marking daemon-mode stale
12
+ * workflows as terminal_error (line 122)
13
+ * BUG-02: Gateway fallback cleans up child sessions via agentSession when
14
+ * subagentRuntime unavailable (lines 148-156)
15
+ * BUG-03: Nocturnal workflow snapshot validation detects pain_context_fallback
16
+ * with zero stats (lines 184-198)
17
+ */
18
+
19
+ import type { WorkspaceContext } from '../core/workspace-context.js';
20
+ import type { OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
21
+ import type { WorkflowRow } from './subagent-workflow/types.js';
22
+ import { WorkflowStore } from './subagent-workflow/workflow-store.js';
23
+ import { isExpectedSubagentError } from './subagent-workflow/subagent-error-utils.js';
24
+ import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
25
+
26
+ export interface WatchdogResult {
27
+ anomalies: number;
28
+ details: string[];
29
+ /** Set when the watchdog scan itself failed (e.g., store errors). Undefined means scan succeeded. */
30
+ scanError?: string;
31
+ }
32
+
33
+ /* eslint-disable complexity */
34
+ export async function runWorkflowWatchdog(
35
+ wctx: WorkspaceContext,
36
+ api: OpenClawPluginApi | null,
37
+ logger?: PluginLogger,
38
+ ): Promise<WatchdogResult> {
39
+ const details: string[] = [];
40
+ const now = Date.now();
41
+ const subagentRuntime = api?.runtime?.subagent;
42
+ const agentSession = api?.runtime?.agent?.session;
43
+
44
+ try {
45
+ const store = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
46
+ try {
47
+ const allWorkflows: WorkflowRow[] = store.listWorkflows();
48
+
49
+ // Check 1: Stale active workflows (active > 2x TTL)
50
+ const staleThreshold = WORKFLOW_TTL_MS * 2;
51
+ const staleActive = allWorkflows.filter(
52
+ (wf: WorkflowRow) => wf.state === 'active' && (now - wf.created_at) > staleThreshold,
53
+ );
54
+ if (staleActive.length > 0) {
55
+ for (const wf of staleActive) {
56
+ const ageMin = Math.round((now - wf.created_at) / 60000);
57
+ details.push(`stale_active: ${wf.workflow_id} (${wf.workflow_type}, ${ageMin}min old)`);
58
+
59
+ // #257: Check if the last recorded event reason indicates expected subagent unavailability.
60
+ // If so, skip marking as terminal_error — the workflow is stale because the subagent
61
+ // was expectedly unavailable (daemon mode, process isolation), not due to a hard failure.
62
+ const events = store.getEvents(wf.workflow_id);
63
+ const lastEventReason = events.length > 0 ? events[events.length - 1].reason : 'unknown';
64
+ if (isExpectedSubagentError(lastEventReason)) {
65
+ logger?.debug?.(`[PD:Watchdog] Skipping stale active workflow ${wf.workflow_id}: expected subagent error (${lastEventReason})`);
66
+ continue;
67
+ }
68
+
69
+ store.updateWorkflowState(wf.workflow_id, 'terminal_error');
70
+ store.recordEvent(wf.workflow_id, 'watchdog_timeout', 'active', 'terminal_error', `Stale active > ${staleThreshold / 60000}s`, { ageMs: now - wf.created_at });
71
+
72
+ // Cleanup session if possible (#188: gateway-safe fallback)
73
+ if (wf.child_session_key) {
74
+ try {
75
+ if (subagentRuntime) {
76
+ await subagentRuntime.deleteSession({ sessionKey: wf.child_session_key, deleteTranscript: true });
77
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session: ${wf.child_session_key}`);
78
+ } else if (agentSession) {
79
+ const storePath = agentSession.resolveStorePath();
80
+ const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
81
+ const normalizedKey = wf.child_session_key.toLowerCase();
82
+ if (sessionStore[normalizedKey]) {
83
+ delete sessionStore[normalizedKey];
84
+ await agentSession.saveSessionStore(storePath, sessionStore);
85
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback: ${wf.child_session_key}`);
86
+ }
87
+ }
88
+ } catch (cleanupErr) {
89
+ const errMsg = String(cleanupErr);
90
+ if (errMsg.includes('gateway request') && agentSession) {
91
+ const storePath = agentSession.resolveStorePath();
92
+ const sessionStore = agentSession.loadSessionStore(storePath, { skipCache: true });
93
+ const normalizedKey = wf.child_session_key.toLowerCase();
94
+ if (sessionStore[normalizedKey]) {
95
+ delete sessionStore[normalizedKey];
96
+ await agentSession.saveSessionStore(storePath, sessionStore);
97
+ logger?.info?.(`[PD:Watchdog] Cleaned up stale session via agentSession fallback after gateway error: ${wf.child_session_key}`);
98
+ }
99
+ } else {
100
+ logger?.warn?.(`[PD:Watchdog] Failed to cleanup session ${wf.child_session_key}: ${errMsg}`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ // Check 2: Workflows in terminal_error/expired without cleanup
108
+ const unclearedTerminal = allWorkflows.filter(
109
+ (wf: WorkflowRow) => (wf.state === 'terminal_error' || wf.state === 'expired') && wf.cleanup_state === 'pending',
110
+ );
111
+ if (unclearedTerminal.length > 0) {
112
+ details.push(`uncleared_terminal: ${unclearedTerminal.length} workflows (will be swept next cycle)`);
113
+ }
114
+
115
+ // Check 3: Nocturnal workflow result validation (#181 pattern)
116
+ const nocturnalCompleted = allWorkflows.filter(
117
+ (wf: WorkflowRow) => wf.workflow_type === 'nocturnal' && wf.state === 'completed',
118
+ );
119
+ for (const wf of nocturnalCompleted) {
120
+ // Check if the metadata snapshot has all zeros (invalid data)
121
+ try {
122
+ const meta = JSON.parse(wf.metadata_json) as Record<string, unknown>;
123
+ const snapshot = meta.snapshot as Record<string, unknown> | undefined;
124
+ if (snapshot) {
125
+ // #219: Check for fallback data source (partial stats from pain context)
126
+ const dataSource = snapshot._dataSource as string | undefined;
127
+ if (dataSource === 'pain_context_fallback') {
128
+ details.push(`fallback_snapshot: nocturnal workflow ${wf.workflow_id} uses pain-context fallback (stats may be incomplete)`);
129
+ }
130
+ const stats = snapshot.stats as Record<string, number> | undefined;
131
+ // #246: Stats are now always number (never null). Detect "empty" fallback:
132
+ // fallback + all counts zero means no real data was available.
133
+ // NOTE: totalAssistantTurns may be 0 even for valid sessions because
134
+ // listRecentNocturnalCandidateSessions (used in fallback path) does not
135
+ // populate assistantTurnCount (only getNocturnalSessionSnapshot does).
136
+ // We use totalToolCalls=0 as the primary indicator instead.
137
+ if (stats && dataSource === 'pain_context_fallback' &&
138
+ stats.totalToolCalls === 0 && stats.totalGateBlocks === 0 &&
139
+ stats.failureCount === 0) {
140
+ details.push(`fallback_snapshot_stats: nocturnal workflow ${wf.workflow_id} has empty fallback stats (no trajectory data found)`);
141
+ }
142
+ }
143
+ } catch (err) {
144
+ details.push(`malformed_metadata: workflow ${wf.workflow_id} has unparseable metadata: ${String(err).slice(0, 100)}`);
145
+ }
146
+ }
147
+
148
+ // Summary
149
+ const stateCounts: Record<string, number> = {};
150
+ for (const wf of allWorkflows) {
151
+ stateCounts[wf.state] = (stateCounts[wf.state] || 0) + 1;
152
+ }
153
+ const stateSummary = Object.entries(stateCounts).map(([s, c]) => `${s}=${c}`).join(', ');
154
+ if (details.length === 0) {
155
+ logger?.debug?.(`[PD:Watchdog] OK — ${allWorkflows.length} workflows (${stateSummary})`);
156
+ } else {
157
+ logger?.info?.(`[PD:Watchdog] ${details.length} anomalies — ${allWorkflows.length} workflows (${stateSummary})`);
158
+ }
159
+ } finally {
160
+ store.dispose();
161
+ }
162
+ } catch (err) {
163
+ logger?.warn?.(`[PD:Watchdog] Failed to scan workflows: ${String(err)}`);
164
+ return { anomalies: -1, details: [], scanError: String(err) };
165
+ }
166
+
167
+ return { anomalies: details.length, details };
168
+ }
@@ -1,4 +1,5 @@
1
1
  import type { OpenClawPluginApi } from '../openclaw-sdk.js';
2
+ import type { PluginRuntimeSubagent } from '../service/subagent-workflow/runtime-direct-driver.js';
2
3
  import { Type } from '@sinclair/typebox';
3
4
  import * as fs from 'fs';
4
5
  import { EventLogService } from '../core/event-log.js';
@@ -23,6 +24,16 @@ interface DeepReflectionConfig {
23
24
  timeout_ms?: number;
24
25
  }
25
26
 
27
+ /**
28
+ * Type assertion: OpenClaw SDK subagent -> workflow manager subagent type.
29
+ * Both types are structurally identical but come from different import paths.
30
+ */
31
+ function toWorkflowSubagent(
32
+ subagent: NonNullable<OpenClawPluginApi['runtime']>['subagent']
33
+ ): PluginRuntimeSubagent {
34
+ return subagent as unknown as PluginRuntimeSubagent;
35
+ }
36
+
26
37
  const DEFAULT_CONFIG: DeepReflectionConfig = {
27
38
  enabled: true,
28
39
  mode: 'auto',
@@ -108,7 +119,7 @@ export function createDeepReflectTool(api: OpenClawPluginApi) {
108
119
  }
109
120
 
110
121
 
111
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
122
+
112
123
  const effectiveWorkspaceDir = resolveReflectionWorkspace(api);
113
124
 
114
125
  const config = loadConfig(effectiveWorkspaceDir, api);
@@ -122,11 +133,11 @@ export function createDeepReflectTool(api: OpenClawPluginApi) {
122
133
 
123
134
  try {
124
135
 
125
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
136
+
126
137
  return await executeReflectionWorkflow(effectiveWorkspaceDir, config, context, depth, model_id, api);
127
138
  } catch (err) {
128
139
 
129
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
140
+
130
141
  return handleReflectionError(err, context, depth, model_id, effectiveWorkspaceDir, api);
131
142
  }
132
143
  }
@@ -149,7 +160,7 @@ function resolveReflectionWorkspace(api: OpenClawPluginApi): string {
149
160
  * Execute the deep reflection workflow: start, poll, collect results.
150
161
  */
151
162
 
152
- // eslint-disable-next-line @typescript-eslint/max-params
163
+
153
164
  async function executeReflectionWorkflow(
154
165
  effectiveWorkspaceDir: string,
155
166
  config: DeepReflectionConfig,
@@ -165,8 +176,8 @@ async function executeReflectionWorkflow(
165
176
  const manager = new DeepReflectWorkflowManager({
166
177
  workspaceDir: effectiveWorkspaceDir,
167
178
  logger: api.logger,
168
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: api.runtime.subagent has structurally compatible shape but differs from PluginRuntimeSubagent due to optional provider/model fields
169
- subagent: api.runtime.subagent as any,
179
+
180
+ subagent: toWorkflowSubagent(api.runtime.subagent),
170
181
  agentSession: api.runtime.agent?.session,
171
182
  });
172
183
 
@@ -181,7 +192,7 @@ async function executeReflectionWorkflow(
181
192
  const startTime = Date.now();
182
193
  const timeoutMs = config.timeout_ms ?? 60000;
183
194
 
184
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
195
+
185
196
  return await pollReflectionCompletion(manager, handle, timeoutMs, startTime, eventLog, effectiveWorkspaceDir, context, model_id, depth);
186
197
  } finally {
187
198
  manager.dispose();
@@ -192,7 +203,7 @@ async function executeReflectionWorkflow(
192
203
  * Poll the reflection workflow until completion, timeout, or error.
193
204
  */
194
205
 
195
- // eslint-disable-next-line @typescript-eslint/max-params
206
+
196
207
  async function pollReflectionCompletion(
197
208
  manager: DeepReflectWorkflowManager,
198
209
  handle: { workflowId: string; childSessionKey: string },
@@ -213,7 +224,7 @@ async function pollReflectionCompletion(
213
224
 
214
225
  if (workflowState === 'completed') {
215
226
 
216
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
227
+
217
228
  return formatReflectionSuccess(handle, context, depth, model_id, startTime, eventLog, workspaceDir);
218
229
  }
219
230
 
@@ -229,7 +240,7 @@ async function pollReflectionCompletion(
229
240
  * Format the success response from a completed reflection.
230
241
  */
231
242
 
232
- // eslint-disable-next-line @typescript-eslint/max-params
243
+
233
244
  function formatReflectionSuccess(
234
245
  handle: { childSessionKey: string },
235
246
  context: string,
@@ -283,7 +294,7 @@ ${insights || '反思完成,详见 REFLECTION_LOG。'}
283
294
  * Handle reflection errors and format error response.
284
295
  */
285
296
 
286
- // eslint-disable-next-line @typescript-eslint/max-params
297
+
287
298
  function handleReflectionError(
288
299
  err: unknown,
289
300
  context: string,
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Discriminated union for EventLogEntry — replaces flat data: Record<string, unknown>.
3
+ * Each union member is keyed on the `type` field for type narrowing.
4
+ */
5
+
6
+ import type {
7
+ ToolCallEventData,
8
+ PainSignalEventData,
9
+ RuleMatchEventData,
10
+ RulePromotionEventData,
11
+ HookExecutionEventData,
12
+ GateBlockEventData,
13
+ GateBypassEventData,
14
+ PlanApprovalEventData,
15
+ EvolutionTaskEventData,
16
+ DeepReflectionEventData,
17
+ EmpathyRollbackEventData,
18
+ EventCategory,
19
+ } from './event-types.js';
20
+
21
+ export type EventLogEntry =
22
+ | { ts: string; date: string; type: 'tool_call'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: ToolCallEventData }
23
+ | { ts: string; date: string; type: 'pain_signal'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: PainSignalEventData }
24
+ | { ts: string; date: string; type: 'rule_match'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: RuleMatchEventData }
25
+ | { ts: string; date: string; type: 'rule_promotion'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: RulePromotionEventData }
26
+ | { ts: string; date: string; type: 'hook_execution'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: HookExecutionEventData }
27
+ | { ts: string; date: string; type: 'gate_block'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: GateBlockEventData }
28
+ | { ts: string; date: string; type: 'gate_bypass'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: GateBypassEventData }
29
+ | { ts: string; date: string; type: 'plan_approval'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: PlanApprovalEventData }
30
+ | { ts: string; date: string; type: 'evolution_task'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: EvolutionTaskEventData }
31
+ | { ts: string; date: string; type: 'deep_reflection'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: DeepReflectionEventData }
32
+ | { ts: string; date: string; type: 'empathy_rollback'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: EmpathyRollbackEventData }
33
+ | { ts: string; date: string; type: 'error'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: Record<string, unknown> }
34
+ | { ts: string; date: string; type: 'warn'; category: EventCategory; sessionId?: string; workspaceDir?: string; data: Record<string, unknown> };
35
+
36
+ // Type predicates for safe narrowing
37
+
38
+ export function isToolCallEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'tool_call' }> {
39
+ return entry.type === 'tool_call';
40
+ }
41
+
42
+ export function isPainSignalEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'pain_signal' }> {
43
+ return entry.type === 'pain_signal';
44
+ }
45
+
46
+ export function isRuleMatchEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'rule_match' }> {
47
+ return entry.type === 'rule_match';
48
+ }
49
+
50
+ export function isRulePromotionEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'rule_promotion' }> {
51
+ return entry.type === 'rule_promotion';
52
+ }
53
+
54
+ export function isHookExecutionEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'hook_execution' }> {
55
+ return entry.type === 'hook_execution';
56
+ }
57
+
58
+ export function isGateBlockEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'gate_block' }> {
59
+ return entry.type === 'gate_block';
60
+ }
61
+
62
+ export function isGateBypassEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'gate_bypass' }> {
63
+ return entry.type === 'gate_bypass';
64
+ }
65
+
66
+ export function isPlanApprovalEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'plan_approval' }> {
67
+ return entry.type === 'plan_approval';
68
+ }
69
+
70
+ export function isEvolutionTaskEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'evolution_task' }> {
71
+ return entry.type === 'evolution_task';
72
+ }
73
+
74
+ export function isDeepReflectionEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'deep_reflection' }> {
75
+ return entry.type === 'deep_reflection';
76
+ }
77
+
78
+ export function isEmpathyRollbackEventEntry(entry: EventLogEntry): entry is Extract<EventLogEntry, { type: 'empathy_rollback' }> {
79
+ return entry.type === 'empathy_rollback';
80
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Branded types for queue and workflow domain identifiers.
3
+ * These prevent accidental interchange of plain strings with domain-specific IDs.
4
+ */
5
+
6
+ /**
7
+ * Brand type constructor using intersection type pattern.
8
+ * @example type UserId = Brand<string, 'UserId'>;
9
+ */
10
+ export type Brand<T, B> = T & { readonly _brand: B };
11
+
12
+ /**
13
+ * Queue item identifier — not interchangeable with plain string.
14
+ */
15
+ export type QueueItemId = Brand<string, 'QueueItemId'>;
16
+
17
+ /**
18
+ * Workflow identifier — not interchangeable with plain string.
19
+ */
20
+ export type WorkflowId = Brand<string, 'WorkflowId'>;
21
+
22
+ /**
23
+ * Session key — not interchangeable with plain string.
24
+ */
25
+ export type SessionKey = Brand<string, 'SessionKey'>;
26
+
27
+ /**
28
+ * Constructor for QueueItemId.
29
+ * @param id - raw string ID from queue operations
30
+ */
31
+ export function toQueueItemId(id: string): QueueItemId {
32
+ return id as QueueItemId;
33
+ }
34
+
35
+ /**
36
+ * Constructor for WorkflowId.
37
+ * @param id - raw string ID from workflow operations
38
+ */
39
+ export function toWorkflowId(id: string): WorkflowId {
40
+ return id as WorkflowId;
41
+ }
42
+
43
+ /**
44
+ * Constructor for SessionKey.
45
+ * @param key - raw string key from session operations
46
+ */
47
+ export function toSessionKey(key: string): SessionKey {
48
+ return key as SessionKey;
49
+ }
50
+
51
+ /**
52
+ * Type predicate: true if value is a QueueItemId.
53
+ */
54
+ export function isQueueItemId(value: unknown): value is QueueItemId {
55
+ return typeof value === 'string';
56
+ }
57
+
58
+ /**
59
+ * Type predicate: true if value is a WorkflowId.
60
+ */
61
+ export function isWorkflowId(value: unknown): value is WorkflowId {
62
+ return typeof value === 'string';
63
+ }
64
+
65
+ /**
66
+ * Type predicate: true if value is a SessionKey.
67
+ */
68
+ export function isSessionKey(value: unknown): value is SessionKey {
69
+ return typeof value === 'string';
70
+ }
@@ -322,7 +322,7 @@ export async function withLockAsync<T>(
322
322
  * 注意:这是一个简化的实现,适用于单进程内的异步并发控制
323
323
  * 对于多进程场景,应使用同步版本的 acquireLock
324
324
  */
325
- const asyncLockQueues = new Map<string, Promise<void>>();
325
+ export const asyncLockQueues = new Map<string, Promise<void>>();
326
326
 
327
327
  export async function withAsyncLock<T>(
328
328
  filePath: string,
@@ -335,7 +335,7 @@ export async function withAsyncLock<T>(
335
335
 
336
336
  // 创建新的 Promise 链
337
337
 
338
- // eslint-disable-next-line @typescript-eslint/init-declarations
338
+
339
339
  let resolveRelease: () => void;
340
340
  const releasePromise = new Promise<void>(resolve => {
341
341
  resolveRelease = resolve;
package/src/utils/io.ts CHANGED
@@ -28,9 +28,17 @@ export function atomicWriteFileSync(filePath: string, data: string): void {
28
28
  if (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES') {
29
29
  if (attempt < RENAME_MAX_RETRIES - 1) {
30
30
  const delay = RENAME_BASE_DELAY_MS * Math.pow(2, attempt);
31
- // Busy-wait is acceptable for very short delays (50-200ms)
32
- const end = Date.now() + delay;
33
- while (Date.now() < end) { /* spin */ }
31
+ // Bounded spin-wait with CPU yield materially different from
32
+ // tight infinite spin; only 50-200ms total across retries.
33
+ const waitUntil = Date.now() + delay;
34
+ let yielded = false;
35
+ while (Date.now() < waitUntil) {
36
+ if (!yielded && Date.now() >= waitUntil - 10) {
37
+ // Last few ms: yield to give other sync code a chance to run
38
+ try { require('fs').accessSync?.(tmpPath); } catch { /* ignore */ }
39
+ yielded = true;
40
+ }
41
+ }
34
42
  }
35
43
  continue;
36
44
  }
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateGeneratedCode } from '../../src/core/principle-compiler/code-validator.js';
3
+
4
+ describe('validateGeneratedCode', () => {
5
+ // --- Valid code ---
6
+
7
+ it('accepts valid rule implementation with evaluate and meta', () => {
8
+ const code = `
9
+ export const meta = {
10
+ name: 'test-rule',
11
+ version: '1.0.0',
12
+ ruleId: 'R-001',
13
+ coversCondition: 'test condition'
14
+ };
15
+
16
+ export function evaluate(input) {
17
+ return {
18
+ matched: input.action.toolName === 'bash',
19
+ reason: 'checked toolName'
20
+ };
21
+ }
22
+ `;
23
+ const result = validateGeneratedCode(code);
24
+ expect(result.valid).toBe(true);
25
+ expect(result.errors).toEqual([]);
26
+ });
27
+
28
+ it('accepts evaluate that returns matched: false', () => {
29
+ const code = `
30
+ export const meta = { name: 'never-match', version: '1.0.0', ruleId: 'R-002', coversCondition: 'none' };
31
+
32
+ export function evaluate(_input) {
33
+ return { matched: false, reason: 'never matches' };
34
+ }
35
+ `;
36
+ const result = validateGeneratedCode(code);
37
+ expect(result.valid).toBe(true);
38
+ });
39
+
40
+ // --- Syntax check ---
41
+
42
+ it('rejects code with syntax errors', () => {
43
+ const code = `function broken( {`;
44
+ const result = validateGeneratedCode(code);
45
+ expect(result.valid).toBe(false);
46
+ expect(result.errors.some((e) => /syntax/i.test(e))).toBe(true);
47
+ });
48
+
49
+ // --- Forbidden patterns ---
50
+
51
+ it('rejects code containing require(', () => {
52
+ const code = `
53
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
54
+ export function evaluate() { const fs = require('fs'); return { matched: false, reason: '' }; }
55
+ `;
56
+ const result = validateGeneratedCode(code);
57
+ expect(result.valid).toBe(false);
58
+ expect(result.errors.some((e) => /require/i.test(e))).toBe(true);
59
+ });
60
+
61
+ it('rejects code containing import statement', () => {
62
+ const code = `import { something } from 'module';
63
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
64
+ export function evaluate() { return { matched: false, reason: '' }; }
65
+ `;
66
+ const result = validateGeneratedCode(code);
67
+ expect(result.valid).toBe(false);
68
+ expect(result.errors.some((e) => /import/i.test(e))).toBe(true);
69
+ });
70
+
71
+ it('rejects code containing fetch(', () => {
72
+ const code = `
73
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
74
+ export function evaluate() { fetch('http://evil.com'); return { matched: false, reason: '' }; }
75
+ `;
76
+ const result = validateGeneratedCode(code);
77
+ expect(result.valid).toBe(false);
78
+ expect(result.errors.some((e) => /fetch/i.test(e))).toBe(true);
79
+ });
80
+
81
+ it('rejects code containing eval(', () => {
82
+ const code = `
83
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
84
+ export function evaluate() { eval('42'); return { matched: false, reason: '' }; }
85
+ `;
86
+ const result = validateGeneratedCode(code);
87
+ expect(result.valid).toBe(false);
88
+ expect(result.errors.some((e) => /eval/i.test(e))).toBe(true);
89
+ });
90
+
91
+ it('rejects code containing Function(', () => {
92
+ const code = `
93
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
94
+ export function evaluate() { new Function('return 1')(); return { matched: false, reason: '' }; }
95
+ `;
96
+ const result = validateGeneratedCode(code);
97
+ expect(result.valid).toBe(false);
98
+ expect(result.errors.some((e) => /Function/i.test(e))).toBe(true);
99
+ });
100
+
101
+ it('rejects code containing process', () => {
102
+ const code = `
103
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
104
+ export function evaluate() { const x = process.env; return { matched: false, reason: '' }; }
105
+ `;
106
+ const result = validateGeneratedCode(code);
107
+ expect(result.valid).toBe(false);
108
+ expect(result.errors.some((e) => /process/i.test(e))).toBe(true);
109
+ });
110
+
111
+ it('rejects code containing globalThis', () => {
112
+ const code = `
113
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
114
+ export function evaluate() { globalThis.foo = 'bar'; return { matched: false, reason: '' }; }
115
+ `;
116
+ const result = validateGeneratedCode(code);
117
+ expect(result.valid).toBe(false);
118
+ expect(result.errors.some((e) => /globalThis/i.test(e))).toBe(true);
119
+ });
120
+
121
+ it('reports all forbidden patterns at once', () => {
122
+ const code = `
123
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
124
+ export function evaluate() {
125
+ require('fs');
126
+ fetch('http://x');
127
+ eval('1');
128
+ process.env;
129
+ return { matched: false, reason: '' };
130
+ }
131
+ `;
132
+ const result = validateGeneratedCode(code);
133
+ expect(result.valid).toBe(false);
134
+ expect(result.errors.length).toBeGreaterThanOrEqual(4);
135
+ });
136
+
137
+ // --- Export check ---
138
+
139
+ it('rejects code that does not export evaluate', () => {
140
+ const code = `
141
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
142
+ // no evaluate function at all
143
+ `;
144
+ const result = validateGeneratedCode(code);
145
+ expect(result.valid).toBe(false);
146
+ expect(result.errors.some((e) => /evaluate/i.test(e))).toBe(true);
147
+ });
148
+
149
+ it('rejects code that does not export meta', () => {
150
+ const code = `
151
+ // no meta at all
152
+ export function evaluate() { return { matched: false, reason: '' }; }
153
+ `;
154
+ const result = validateGeneratedCode(code);
155
+ expect(result.valid).toBe(false);
156
+ expect(result.errors.some((e) => /meta/i.test(e))).toBe(true);
157
+ });
158
+
159
+ // --- Return shape check ---
160
+
161
+ it('rejects evaluate that returns object without matched', () => {
162
+ const code = `
163
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
164
+ export function evaluate() { return { reason: 'no matched field' }; }
165
+ `;
166
+ const result = validateGeneratedCode(code);
167
+ expect(result.valid).toBe(false);
168
+ expect(result.errors.some((e) => /matched/i.test(e))).toBe(true);
169
+ });
170
+
171
+ it('accepts evaluate even when it throws on mock input', () => {
172
+ // evaluate throws because it accesses a nested property that doesn't exist in mock
173
+ const code = `
174
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
175
+ export function evaluate(input) {
176
+ if (input.action.toolName === 'bash') {
177
+ return { matched: true, reason: 'ok' };
178
+ }
179
+ throw new Error('unexpected input');
180
+ }
181
+ `;
182
+ // The mock input has toolName: 'bash', so it returns successfully
183
+ const result = validateGeneratedCode(code);
184
+ expect(result.valid).toBe(true);
185
+ });
186
+
187
+ it('accepts evaluate that throws as long as it has correct shape when it returns', () => {
188
+ const code = `
189
+ export const meta = { name: 'x', version: '1', ruleId: 'R', coversCondition: 'c' };
190
+ export function evaluate(_input) {
191
+ return { matched: true, reason: 'ok' };
192
+ }
193
+ `;
194
+ const result = validateGeneratedCode(code);
195
+ expect(result.valid).toBe(true);
196
+ });
197
+ });