keystone-cli 2.0.0 → 2.1.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/README.md +43 -4
- package/package.json +4 -1
- package/src/cli.ts +1 -0
- package/src/commands/event.ts +9 -0
- package/src/commands/run.ts +17 -0
- package/src/db/dynamic-state-manager.ts +12 -9
- package/src/db/memory-db.test.ts +19 -1
- package/src/db/memory-db.ts +101 -22
- package/src/db/workflow-db.ts +181 -9
- package/src/expression/evaluator.ts +4 -1
- package/src/parser/config-schema.ts +6 -0
- package/src/parser/schema.ts +1 -0
- package/src/runner/__test__/llm-test-setup.ts +43 -11
- package/src/runner/durable-timers.test.ts +1 -1
- package/src/runner/executors/dynamic-executor.ts +125 -88
- package/src/runner/executors/engine-executor.ts +10 -39
- package/src/runner/executors/file-executor.ts +67 -0
- package/src/runner/executors/foreach-executor.ts +170 -17
- package/src/runner/executors/human-executor.ts +18 -0
- package/src/runner/executors/llm/stream-handler.ts +103 -0
- package/src/runner/executors/llm/tool-manager.ts +360 -0
- package/src/runner/executors/llm-executor.ts +288 -555
- package/src/runner/executors/memory-executor.ts +41 -34
- package/src/runner/executors/shell-executor.ts +96 -52
- package/src/runner/executors/subworkflow-executor.ts +16 -0
- package/src/runner/executors/types.ts +3 -1
- package/src/runner/executors/verification_fixes.test.ts +46 -0
- package/src/runner/join-scheduling.test.ts +2 -1
- package/src/runner/llm-adapter.integration.test.ts +10 -5
- package/src/runner/llm-adapter.ts +57 -18
- package/src/runner/llm-clarification.test.ts +4 -1
- package/src/runner/llm-executor.test.ts +21 -7
- package/src/runner/mcp-client.ts +36 -2
- package/src/runner/mcp-server.ts +65 -36
- package/src/runner/recovery-security.test.ts +5 -2
- package/src/runner/reflexion.test.ts +6 -3
- package/src/runner/services/context-builder.ts +13 -4
- package/src/runner/services/workflow-validator.ts +2 -1
- package/src/runner/standard-tools-ast.test.ts +4 -2
- package/src/runner/standard-tools-execution.test.ts +14 -1
- package/src/runner/standard-tools-integration.test.ts +6 -0
- package/src/runner/standard-tools.ts +13 -10
- package/src/runner/step-executor.ts +2 -2
- package/src/runner/tool-integration.test.ts +4 -1
- package/src/runner/workflow-runner.test.ts +23 -12
- package/src/runner/workflow-runner.ts +172 -79
- package/src/runner/workflow-state.ts +181 -111
- package/src/ui/dashboard.tsx +17 -3
- package/src/utils/config-loader.ts +4 -0
- package/src/utils/constants.ts +4 -0
- package/src/utils/context-injector.test.ts +27 -27
- package/src/utils/context-injector.ts +68 -26
- package/src/utils/process-sandbox.ts +138 -148
- package/src/utils/redactor.ts +39 -9
- package/src/utils/resource-loader.ts +24 -19
- package/src/utils/sandbox.ts +6 -0
- package/src/utils/stream-utils.ts +58 -0
|
@@ -107,143 +107,213 @@ export class WorkflowState {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// Load all step executions for this run
|
|
111
|
-
const steps = await this.db.getStepsByRun(this.runId);
|
|
112
|
-
|
|
113
|
-
// Group steps by step_id
|
|
114
|
-
const stepExecutionsByStepId = new Map<string, typeof steps>();
|
|
115
|
-
for (const step of steps) {
|
|
116
|
-
if (!stepExecutionsByStepId.has(step.step_id)) {
|
|
117
|
-
stepExecutionsByStepId.set(step.step_id, []);
|
|
118
|
-
}
|
|
119
|
-
stepExecutionsByStepId.get(step.step_id)?.push(step);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
110
|
const executionOrder = WorkflowParser.topologicalSort(this.workflow);
|
|
123
111
|
|
|
124
112
|
for (const stepId of executionOrder) {
|
|
125
|
-
const stepExecutions = stepExecutionsByStepId.get(stepId);
|
|
126
|
-
if (!stepExecutions || stepExecutions.length === 0) continue;
|
|
127
|
-
|
|
128
113
|
const stepDef = this.workflow.steps.find((s) => s.id === stepId);
|
|
129
114
|
if (!stepDef) continue;
|
|
130
115
|
|
|
116
|
+
// Fetch the main execution record for this step
|
|
117
|
+
const mainExec = await this.db.getMainStep(this.runId, stepId);
|
|
118
|
+
|
|
119
|
+
// If no execution exists, nothing to restore for this step
|
|
120
|
+
if (!mainExec) continue;
|
|
121
|
+
|
|
131
122
|
const isForeach = !!stepDef.foreach;
|
|
132
123
|
|
|
133
124
|
if (isForeach) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
125
|
+
// Optimization: If the foreach step completed successfully, we don't need to fetch all iterations
|
|
126
|
+
// We can just rely on the stored output in the parent record.
|
|
127
|
+
if (mainExec.status === StepStatus.SUCCESS || mainExec.status === StepStatus.SKIPPED) {
|
|
128
|
+
let outputs: unknown[] = [];
|
|
129
|
+
let mappedOutputs: unknown = {};
|
|
130
|
+
let persistedItems: unknown[] | undefined;
|
|
131
|
+
|
|
132
|
+
if (mainExec.output) {
|
|
133
|
+
try {
|
|
134
|
+
outputs = JSON.parse(mainExec.output);
|
|
135
|
+
// If output is not an array, something is wrong, but we handle it gracefully
|
|
136
|
+
if (!Array.isArray(outputs)) outputs = [];
|
|
137
|
+
} catch {
|
|
138
|
+
/* ignore */
|
|
139
|
+
}
|
|
147
140
|
}
|
|
148
|
-
if (a.step_id && b.step_id) return 0; // Stability
|
|
149
|
-
return 0;
|
|
150
|
-
});
|
|
151
141
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
142
|
+
// Restore items from outputs if possible, but we won't have individual item status/error
|
|
143
|
+
// This is acceptable for a successful step.
|
|
144
|
+
// However, to be perfectly safe and support `items` context usage in downstream steps,
|
|
145
|
+
// we should populate the `items` array with dummy success contexts or the actual output.
|
|
146
|
+
|
|
147
|
+
// Reconstruct items from outputs
|
|
148
|
+
const items: StepContext[] = outputs.map((out) => ({
|
|
149
|
+
output: out,
|
|
150
|
+
outputs:
|
|
151
|
+
typeof out === 'object' && out !== null && !Array.isArray(out) ? (out as any) : {},
|
|
152
|
+
status: StepStatus.SUCCESS,
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// We also need to reconstruct mappedOutputs (hash map)
|
|
156
|
+
// But wait, the parent record doesn't store the mapped outputs explicitly in a separate column?
|
|
157
|
+
// `WorkflowState` usually stores `output` (array) and `outputs` (map).
|
|
158
|
+
// But `db.completeStep` stores `output`.
|
|
159
|
+
// Ideally `db` should store both or we re-derive `outputs`.
|
|
160
|
+
// `ForeachExecutor.aggregateOutputs` can re-derive it.
|
|
161
|
+
mappedOutputs = ForeachExecutor.aggregateOutputs(outputs);
|
|
162
|
+
|
|
163
|
+
// Try to recover persisted execution state (foreachItems) if it was stored in output?
|
|
164
|
+
// Actually, we look for `__foreachItems` in the output? No, that was a hack in the previous code.
|
|
165
|
+
// Previous code: `const parsed = JSON.parse(parentExec.output); if (parsed.__foreachItems) ...`
|
|
166
|
+
// If that hack exists, we should preserve "restore items".
|
|
167
|
+
|
|
168
|
+
this.stepContexts.set(stepId, {
|
|
169
|
+
output: outputs,
|
|
170
|
+
outputs: mappedOutputs as Record<string, unknown>,
|
|
171
|
+
status: mainExec.status as StepStatusType,
|
|
172
|
+
items,
|
|
173
|
+
foreachItems: persistedItems,
|
|
174
|
+
} as ForeachStepContext);
|
|
175
|
+
} else {
|
|
176
|
+
// Step failed or incomplete: We need full iteration history to determine what to retry
|
|
162
177
|
|
|
163
|
-
|
|
164
|
-
|
|
178
|
+
// Optimization: Check count first to decide if we should load outputs to prevent OOM
|
|
179
|
+
const count = await this.db.countStepIterations(this.runId, stepId);
|
|
180
|
+
const LARGE_DATASET_THRESHOLD = 500;
|
|
181
|
+
const isLargeDataset = count > LARGE_DATASET_THRESHOLD;
|
|
165
182
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
const stepExecutions = await this.db.getStepIterations(this.runId, stepId, {
|
|
184
|
+
includeOutput: !isLargeDataset,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Reconstruct logic (dedup, sort)
|
|
188
|
+
const items: StepContext[] = [];
|
|
189
|
+
const outputs: unknown[] = [];
|
|
190
|
+
let allSuccess = true;
|
|
191
|
+
|
|
192
|
+
const sortedExecs = [...stepExecutions].sort((a, b) => {
|
|
193
|
+
if ((a.iteration_index ?? 0) !== (b.iteration_index ?? 0)) {
|
|
194
|
+
return (a.iteration_index ?? 0) - (b.iteration_index ?? 0);
|
|
195
|
+
}
|
|
196
|
+
if (a.started_at && b.started_at) {
|
|
197
|
+
return new Date(b.started_at).getTime() - new Date(a.started_at).getTime();
|
|
198
|
+
}
|
|
199
|
+
return 0;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const uniqueExecs: typeof stepExecutions = [];
|
|
203
|
+
const seenIndices = new Set<number>();
|
|
204
|
+
for (const ex of sortedExecs) {
|
|
205
|
+
const idx = ex.iteration_index ?? 0;
|
|
206
|
+
if (!seenIndices.has(idx)) {
|
|
207
|
+
seenIndices.add(idx);
|
|
208
|
+
uniqueExecs.push(ex);
|
|
172
209
|
}
|
|
173
210
|
}
|
|
174
211
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
212
|
+
for (const exec of uniqueExecs) {
|
|
213
|
+
if (exec.iteration_index === null) continue; // Should not happen with getStepIterations
|
|
214
|
+
|
|
215
|
+
let output: unknown = null;
|
|
216
|
+
// Only hydrate full output if dataset is small, otherwise save memory
|
|
217
|
+
// We still need output for aggregation if we want to support it, but for OOM prevention we skip it.
|
|
218
|
+
// If the user needs the output of a 10k items loop, they should use a file or DB directly.
|
|
219
|
+
if (!isLargeDataset && exec.output) {
|
|
220
|
+
try {
|
|
221
|
+
output = JSON.parse(exec.output);
|
|
222
|
+
} catch (e) {}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
items[exec.iteration_index] = {
|
|
226
|
+
output,
|
|
227
|
+
outputs:
|
|
228
|
+
typeof output === 'object' && output !== null && !Array.isArray(output)
|
|
229
|
+
? (output as any)
|
|
230
|
+
: {},
|
|
231
|
+
status: exec.status as StepStatusType,
|
|
232
|
+
error: exec.error || undefined,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (!isLargeDataset) {
|
|
236
|
+
outputs[exec.iteration_index] = output;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (exec.status !== StepStatus.SUCCESS && exec.status !== StepStatus.SKIPPED) {
|
|
240
|
+
allSuccess = false;
|
|
241
|
+
}
|
|
187
242
|
}
|
|
188
|
-
}
|
|
189
243
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
persistedItems = parsed.__foreachItems;
|
|
199
|
-
expectedCount = parsed.__foreachItems.length;
|
|
244
|
+
// Ensure items array is dense to prevent crashes on iteration of sparse arrays
|
|
245
|
+
for (let i = 0; i < items.length; i++) {
|
|
246
|
+
if (!items[i]) {
|
|
247
|
+
items[i] = {
|
|
248
|
+
status: StepStatus.PENDING,
|
|
249
|
+
output: null,
|
|
250
|
+
outputs: {},
|
|
251
|
+
};
|
|
200
252
|
}
|
|
201
|
-
} catch {
|
|
202
|
-
/* ignore */
|
|
203
253
|
}
|
|
204
|
-
}
|
|
205
254
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
255
|
+
// Re-evaluate foreachItems to calculate expectedCount if needed
|
|
256
|
+
// ... same logic as before ...
|
|
257
|
+
// For brevity, we copy the basic logic
|
|
258
|
+
let expectedCount = -1;
|
|
259
|
+
let persistedItems: unknown[] | undefined;
|
|
260
|
+
if (mainExec.output) {
|
|
261
|
+
// Use mainExec output for persistence check
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(mainExec.output);
|
|
264
|
+
if (parsed.__foreachItems && Array.isArray(parsed.__foreachItems)) {
|
|
265
|
+
persistedItems = parsed.__foreachItems;
|
|
266
|
+
expectedCount = parsed.length; // Actually __foreachItems.length?
|
|
267
|
+
// The original code:
|
|
268
|
+
// if (parsed.__foreachItems && Array.isArray(parsed.__foreachItems)) {
|
|
269
|
+
// persistedItems = parsed.__foreachItems;
|
|
270
|
+
// expectedCount = parsed.__foreachItems.length;
|
|
271
|
+
// }
|
|
272
|
+
expectedCount = (persistedItems as any[]).length;
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
213
275
|
}
|
|
214
|
-
}
|
|
215
276
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
277
|
+
if (expectedCount === -1 && stepDef.foreach) {
|
|
278
|
+
try {
|
|
279
|
+
const baseContext = this.buildContext();
|
|
280
|
+
const foreachItems = ExpressionEvaluator.evaluate(stepDef.foreach, baseContext);
|
|
281
|
+
if (Array.isArray(foreachItems)) expectedCount = foreachItems.length;
|
|
282
|
+
} catch {
|
|
283
|
+
allSuccess = false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
220
286
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
status = StepStatus.SUSPENDED;
|
|
226
|
-
} else {
|
|
227
|
-
status = StepStatus.FAILED;
|
|
228
|
-
}
|
|
287
|
+
const hasAllItems =
|
|
288
|
+
expectedCount !== -1 &&
|
|
289
|
+
items.length === expectedCount &&
|
|
290
|
+
!Array.from({ length: expectedCount }).some((_, i) => !items[i]);
|
|
229
291
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
status,
|
|
235
|
-
items,
|
|
236
|
-
foreachItems: persistedItems,
|
|
237
|
-
} as ForeachStepContext);
|
|
238
|
-
} else {
|
|
239
|
-
// Fix: Sort by started_at desc (newest first) to avoid restoring stale retries
|
|
240
|
-
const sorted = [...stepExecutions].sort((a, b) => {
|
|
241
|
-
if (a.started_at && b.started_at) {
|
|
242
|
-
return new Date(b.started_at).getTime() - new Date(a.started_at).getTime();
|
|
292
|
+
if (isLargeDataset) {
|
|
293
|
+
this.logger.warn(
|
|
294
|
+
`Optimization: Large dataset detected (${uniqueExecs.length} items). Skipping output aggregation for step "${stepId}" to prevent memory issues.`
|
|
295
|
+
);
|
|
243
296
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
297
|
+
const mappedOutputs = isLargeDataset ? {} : ForeachExecutor.aggregateOutputs(outputs);
|
|
298
|
+
this.stepContexts.set(stepId, {
|
|
299
|
+
output: isLargeDataset ? [] : outputs,
|
|
300
|
+
outputs: mappedOutputs,
|
|
301
|
+
status: mainExec.status as StepStatusType, // Trust the main status mostly? Or recompute?
|
|
302
|
+
// If main status says STARTED but we have all items success, maybe we should trust our recomputation?
|
|
303
|
+
// The original code sets status based on items.
|
|
304
|
+
// But if mainExec exists and has a status, that's authoritative for the "Parent".
|
|
305
|
+
// HOWEVER, if we are resuming, we might want to check if it matches reality.
|
|
306
|
+
// Let's stick to original logic:
|
|
307
|
+
// if (allSuccess && hasAllItems) status = SUCCESS...
|
|
308
|
+
// But wait, if main status is FAILED, using FAILED is correct.
|
|
309
|
+
// Let's mostly use the derived status for consistency in "incomplete" resumes.
|
|
310
|
+
items,
|
|
311
|
+
foreachItems: persistedItems,
|
|
312
|
+
} as ForeachStepContext);
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
// Not a foreach step
|
|
316
|
+
const exec = mainExec;
|
|
247
317
|
let output: unknown = null;
|
|
248
318
|
if (exec.output) {
|
|
249
319
|
try {
|
package/src/ui/dashboard.tsx
CHANGED
|
@@ -107,9 +107,23 @@ const Dashboard = () => {
|
|
|
107
107
|
}, [db]);
|
|
108
108
|
|
|
109
109
|
useEffect(() => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
let timer: Timer;
|
|
111
|
+
let cancelled = false;
|
|
112
|
+
|
|
113
|
+
const loop = async () => {
|
|
114
|
+
if (cancelled) return;
|
|
115
|
+
await fetchData();
|
|
116
|
+
if (!cancelled) {
|
|
117
|
+
timer = setTimeout(loop, 2000);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
void loop();
|
|
122
|
+
|
|
123
|
+
return () => {
|
|
124
|
+
cancelled = true;
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
};
|
|
113
127
|
}, [fetchData]);
|
|
114
128
|
|
|
115
129
|
useInput((input) => {
|
|
@@ -20,6 +20,9 @@ export class ConfigLoader {
|
|
|
20
20
|
const output = { ...target };
|
|
21
21
|
if (source && typeof source === 'object' && !Array.isArray(source)) {
|
|
22
22
|
for (const key of Object.keys(source)) {
|
|
23
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
23
26
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
24
27
|
if (!(key in target)) {
|
|
25
28
|
Object.assign(output, { [key]: source[key] });
|
|
@@ -30,6 +33,7 @@ export class ConfigLoader {
|
|
|
30
33
|
);
|
|
31
34
|
}
|
|
32
35
|
} else {
|
|
36
|
+
// Arrays and primitives are replaced, not merged. This is intentional for configuration lists.
|
|
33
37
|
Object.assign(output, { [key]: source[key] });
|
|
34
38
|
}
|
|
35
39
|
}
|
package/src/utils/constants.ts
CHANGED
|
@@ -91,12 +91,16 @@ export const LIMITS = {
|
|
|
91
91
|
export const FILE_MODES = {
|
|
92
92
|
/** Owner-only permissions for sensitive temp directories */
|
|
93
93
|
SECURE_DIR: 0o700,
|
|
94
|
+
/** Owner-only read/write for sensitive files (600) */
|
|
95
|
+
SECURE_FILE: 0o600,
|
|
94
96
|
} as const;
|
|
95
97
|
|
|
96
98
|
/** Default iteration counts */
|
|
97
99
|
export const ITERATIONS = {
|
|
98
100
|
/** Default max iterations for LLM ReAct loop */
|
|
99
101
|
DEFAULT_LLM_MAX_ITERATIONS: 10,
|
|
102
|
+
/** Maximum number of agent handoffs allowed to prevent infinite loops */
|
|
103
|
+
MAX_AGENT_HANDOFFS: 20,
|
|
100
104
|
} as const;
|
|
101
105
|
|
|
102
106
|
/** LLM-related constants for conversation management */
|
|
@@ -17,79 +17,79 @@ describe('ContextInjector', () => {
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
describe('findProjectRoot', () => {
|
|
20
|
-
it('should find project root with .git directory', () => {
|
|
20
|
+
it('should find project root with .git directory', async () => {
|
|
21
21
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
22
22
|
const subDir = path.join(tempDir, 'src', 'components');
|
|
23
23
|
fs.mkdirSync(subDir, { recursive: true });
|
|
24
24
|
|
|
25
|
-
const root = ContextInjector.findProjectRoot(subDir);
|
|
25
|
+
const root = await ContextInjector.findProjectRoot(subDir);
|
|
26
26
|
expect(root).toBe(tempDir);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it('should find project root with package.json', () => {
|
|
29
|
+
it('should find project root with package.json', async () => {
|
|
30
30
|
fs.writeFileSync(path.join(tempDir, 'package.json'), '{}');
|
|
31
31
|
const subDir = path.join(tempDir, 'lib');
|
|
32
32
|
fs.mkdirSync(subDir, { recursive: true });
|
|
33
33
|
|
|
34
|
-
const root = ContextInjector.findProjectRoot(subDir);
|
|
34
|
+
const root = await ContextInjector.findProjectRoot(subDir);
|
|
35
35
|
expect(root).toBe(tempDir);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
it('should return start path if no marker found', () => {
|
|
38
|
+
it('should return start path if no marker found', async () => {
|
|
39
39
|
const subDir = path.join(tempDir, 'nomarker');
|
|
40
40
|
fs.mkdirSync(subDir, { recursive: true });
|
|
41
41
|
|
|
42
|
-
const root = ContextInjector.findProjectRoot(subDir);
|
|
42
|
+
const root = await ContextInjector.findProjectRoot(subDir);
|
|
43
43
|
expect(root).toBe(subDir);
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
describe('scanDirectoryContext', () => {
|
|
48
|
-
it('should find README.md in parent directory', () => {
|
|
48
|
+
it('should find README.md in parent directory', async () => {
|
|
49
49
|
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test Project');
|
|
50
50
|
const subDir = path.join(tempDir, 'src');
|
|
51
51
|
fs.mkdirSync(subDir, { recursive: true });
|
|
52
52
|
fs.mkdirSync(path.join(tempDir, '.git')); // Mark project root
|
|
53
53
|
|
|
54
|
-
const context = ContextInjector.scanDirectoryContext(subDir, 3);
|
|
54
|
+
const context = await ContextInjector.scanDirectoryContext(subDir, 3);
|
|
55
55
|
expect(context.readme).toBe('# Test Project');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it('should find AGENTS.md in parent directory', () => {
|
|
58
|
+
it('should find AGENTS.md in parent directory', async () => {
|
|
59
59
|
fs.writeFileSync(path.join(tempDir, 'AGENTS.md'), '# Agent Guidelines');
|
|
60
60
|
const subDir = path.join(tempDir, 'src');
|
|
61
61
|
fs.mkdirSync(subDir, { recursive: true });
|
|
62
62
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
63
63
|
|
|
64
|
-
const context = ContextInjector.scanDirectoryContext(subDir, 3);
|
|
64
|
+
const context = await ContextInjector.scanDirectoryContext(subDir, 3);
|
|
65
65
|
expect(context.agentsMd).toBe('# Agent Guidelines');
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
it('should prefer closer files over distant ones', () => {
|
|
68
|
+
it('should prefer closer files over distant ones', async () => {
|
|
69
69
|
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Root README');
|
|
70
70
|
const subDir = path.join(tempDir, 'src');
|
|
71
71
|
fs.mkdirSync(subDir, { recursive: true });
|
|
72
72
|
fs.writeFileSync(path.join(subDir, 'README.md'), '# Src README');
|
|
73
73
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
74
74
|
|
|
75
|
-
const context = ContextInjector.scanDirectoryContext(subDir, 3);
|
|
75
|
+
const context = await ContextInjector.scanDirectoryContext(subDir, 3);
|
|
76
76
|
expect(context.readme).toBe('# Src README');
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
it('should respect depth limit', () => {
|
|
79
|
+
it('should respect depth limit', async () => {
|
|
80
80
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
81
81
|
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Root README');
|
|
82
82
|
const deepDir = path.join(tempDir, 'a', 'b', 'c', 'd');
|
|
83
83
|
fs.mkdirSync(deepDir, { recursive: true });
|
|
84
84
|
|
|
85
85
|
// With depth 2, shouldn't find the README that's 4 levels up
|
|
86
|
-
const context = ContextInjector.scanDirectoryContext(deepDir, 2);
|
|
86
|
+
const context = await ContextInjector.scanDirectoryContext(deepDir, 2);
|
|
87
87
|
expect(context.readme).toBeUndefined();
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
describe('scanRules', () => {
|
|
92
|
-
it('should find cursor rules', () => {
|
|
92
|
+
it('should find cursor rules', async () => {
|
|
93
93
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
94
94
|
fs.mkdirSync(path.join(tempDir, '.cursor', 'rules'), { recursive: true });
|
|
95
95
|
fs.writeFileSync(
|
|
@@ -97,22 +97,22 @@ describe('ContextInjector', () => {
|
|
|
97
97
|
'Always use TypeScript'
|
|
98
98
|
);
|
|
99
99
|
|
|
100
|
-
const rules = ContextInjector.scanRules([path.join(tempDir, 'src', 'test.ts')]);
|
|
100
|
+
const rules = await ContextInjector.scanRules([path.join(tempDir, 'src', 'test.ts')]);
|
|
101
101
|
expect(rules).toContain('Always use TypeScript');
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
it('should find claude rules', () => {
|
|
104
|
+
it('should find claude rules', async () => {
|
|
105
105
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
106
106
|
fs.mkdirSync(path.join(tempDir, '.claude', 'rules'), { recursive: true });
|
|
107
107
|
fs.writeFileSync(path.join(tempDir, '.claude', 'rules', 'style.md'), 'Use 2 spaces');
|
|
108
108
|
|
|
109
|
-
const rules = ContextInjector.scanRules([path.join(tempDir, 'src', 'test.ts')]);
|
|
109
|
+
const rules = await ContextInjector.scanRules([path.join(tempDir, 'src', 'test.ts')]);
|
|
110
110
|
expect(rules).toContain('Use 2 spaces');
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
it('should return empty array if no rules directory', () => {
|
|
113
|
+
it('should return empty array if no rules directory', async () => {
|
|
114
114
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
115
|
-
const rules = ContextInjector.scanRules([path.join(tempDir, 'src', 'test.ts')]);
|
|
115
|
+
const rules = await ContextInjector.scanRules([path.join(tempDir, 'src', 'test.ts')]);
|
|
116
116
|
expect(rules).toEqual([]);
|
|
117
117
|
});
|
|
118
118
|
});
|
|
@@ -152,8 +152,8 @@ describe('ContextInjector', () => {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
describe('getContext', () => {
|
|
155
|
-
it('should return empty context when disabled', () => {
|
|
156
|
-
const context = ContextInjector.getContext(tempDir, [], {
|
|
155
|
+
it('should return empty context when disabled', async () => {
|
|
156
|
+
const context = await ContextInjector.getContext(tempDir, [], {
|
|
157
157
|
enabled: false,
|
|
158
158
|
search_depth: 3,
|
|
159
159
|
sources: ['readme', 'agents_md', 'cursor_rules'],
|
|
@@ -161,12 +161,12 @@ describe('ContextInjector', () => {
|
|
|
161
161
|
expect(context).toEqual({});
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
it('should only return requested sources', () => {
|
|
164
|
+
it('should only return requested sources', async () => {
|
|
165
165
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
166
166
|
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test');
|
|
167
167
|
fs.writeFileSync(path.join(tempDir, 'AGENTS.md'), '# Agents');
|
|
168
168
|
|
|
169
|
-
const context = ContextInjector.getContext(tempDir, [], {
|
|
169
|
+
const context = await ContextInjector.getContext(tempDir, [], {
|
|
170
170
|
enabled: true,
|
|
171
171
|
search_depth: 3,
|
|
172
172
|
sources: ['readme'],
|
|
@@ -175,7 +175,7 @@ describe('ContextInjector', () => {
|
|
|
175
175
|
expect(context.agentsMd).toBeUndefined();
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it('should use cache on repeated calls', () => {
|
|
178
|
+
it('should use cache on repeated calls', async () => {
|
|
179
179
|
fs.mkdirSync(path.join(tempDir, '.git'));
|
|
180
180
|
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Original');
|
|
181
181
|
|
|
@@ -186,14 +186,14 @@ describe('ContextInjector', () => {
|
|
|
186
186
|
};
|
|
187
187
|
|
|
188
188
|
// First call
|
|
189
|
-
const context1 = ContextInjector.getContext(tempDir, [], config);
|
|
189
|
+
const context1 = await ContextInjector.getContext(tempDir, [], config);
|
|
190
190
|
expect(context1.readme).toBe('# Original');
|
|
191
191
|
|
|
192
192
|
// Modify file
|
|
193
193
|
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Modified');
|
|
194
194
|
|
|
195
195
|
// Second call should return cached value
|
|
196
|
-
const context2 = ContextInjector.getContext(tempDir, [], config);
|
|
196
|
+
const context2 = await ContextInjector.getContext(tempDir, [], config);
|
|
197
197
|
expect(context2.readme).toBe('# Original');
|
|
198
198
|
});
|
|
199
199
|
});
|