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.
- package/.planning/codebase/ARCHITECTURE.md +157 -0
- package/.planning/codebase/CONCERNS.md +145 -0
- package/.planning/codebase/CONVENTIONS.md +148 -0
- package/.planning/codebase/INTEGRATIONS.md +81 -0
- package/.planning/codebase/STACK.md +87 -0
- package/.planning/codebase/STRUCTURE.md +193 -0
- package/.planning/codebase/TESTING.md +243 -0
- package/esbuild.config.js +32 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/compile-principles.mjs +94 -0
- package/scripts/sync-plugin.mjs +96 -281
- package/src/commands/pain.ts +12 -5
- package/src/commands/promote-impl.ts +13 -7
- package/src/commands/rollback.ts +10 -3
- package/src/core/event-log.ts +8 -6
- package/src/core/evolution-types.ts +33 -1
- package/src/core/principle-compiler/code-validator.ts +120 -0
- package/src/core/principle-compiler/compiler.ts +242 -0
- package/src/core/principle-compiler/index.ts +10 -0
- package/src/core/principle-compiler/ledger-registrar.ts +107 -0
- package/src/core/principle-compiler/template-generator.ts +108 -0
- package/src/core/reflection/reflection-context.ts +228 -0
- package/src/hooks/message-sanitize.ts +18 -5
- package/src/hooks/prompt.ts +15 -4
- package/src/hooks/subagent.ts +2 -3
- package/src/http/principles-console-route.ts +21 -4
- package/src/service/evolution-worker.ts +89 -365
- package/src/service/queue-io.ts +375 -0
- package/src/service/queue-migration.ts +122 -0
- package/src/service/sleep-cycle.ts +157 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
- package/src/service/workflow-watchdog.ts +168 -0
- package/src/tools/deep-reflect.ts +22 -11
- package/src/types/event-payload.ts +80 -0
- package/src/types/queue.ts +70 -0
- package/src/utils/file-lock.ts +2 -2
- package/src/utils/io.ts +11 -3
- package/tests/core/code-validator.test.ts +197 -0
- package/tests/core/evolution-migration.test.ts +325 -1
- package/tests/core/ledger-registrar.test.ts +232 -0
- package/tests/core/principle-compiler.test.ts +348 -0
- package/tests/core/queue-purge.test.ts +337 -0
- package/tests/core/reflection-context.test.ts +356 -0
- package/tests/core/template-generator.test.ts +101 -0
- package/tests/fixtures/legacy-queue-v1.json +74 -0
- package/tests/integration/principle-compiler-e2e.test.ts +335 -0
- package/tests/queue/async-lock.test.ts +200 -0
- package/tests/service/evolution-worker.queue.test.ts +296 -0
- package/tests/service/queue-io.test.ts +229 -0
- package/tests/service/queue-migration.test.ts +147 -0
- 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
|
-
|
|
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
|
-
|
|
136
|
+
|
|
126
137
|
return await executeReflectionWorkflow(effectiveWorkspaceDir, config, context, depth, model_id, api);
|
|
127
138
|
} catch (err) {
|
|
128
139
|
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
subagent: api.runtime.subagent
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/file-lock.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
});
|