@aigne/ash 0.0.1
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/DESIGN.md +41 -0
- package/dist/ai-dev-loop/ash-run-result.cjs +12 -0
- package/dist/ai-dev-loop/ash-run-result.d.cts +28 -0
- package/dist/ai-dev-loop/ash-run-result.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-run-result.d.mts +28 -0
- package/dist/ai-dev-loop/ash-run-result.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-run-result.mjs +11 -0
- package/dist/ai-dev-loop/ash-run-result.mjs.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.cjs +51 -0
- package/dist/ai-dev-loop/ash-typed-error.d.cts +54 -0
- package/dist/ai-dev-loop/ash-typed-error.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.d.mts +54 -0
- package/dist/ai-dev-loop/ash-typed-error.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-typed-error.mjs +50 -0
- package/dist/ai-dev-loop/ash-typed-error.mjs.map +1 -0
- package/dist/ai-dev-loop/ash-validate.cjs +27 -0
- package/dist/ai-dev-loop/ash-validate.d.cts +7 -0
- package/dist/ai-dev-loop/ash-validate.d.cts.map +1 -0
- package/dist/ai-dev-loop/ash-validate.d.mts +7 -0
- package/dist/ai-dev-loop/ash-validate.d.mts.map +1 -0
- package/dist/ai-dev-loop/ash-validate.mjs +28 -0
- package/dist/ai-dev-loop/ash-validate.mjs.map +1 -0
- package/dist/ai-dev-loop/dev-loop.cjs +134 -0
- package/dist/ai-dev-loop/dev-loop.d.cts +28 -0
- package/dist/ai-dev-loop/dev-loop.d.cts.map +1 -0
- package/dist/ai-dev-loop/dev-loop.d.mts +28 -0
- package/dist/ai-dev-loop/dev-loop.d.mts.map +1 -0
- package/dist/ai-dev-loop/dev-loop.mjs +135 -0
- package/dist/ai-dev-loop/dev-loop.mjs.map +1 -0
- package/dist/ai-dev-loop/index.cjs +24 -0
- package/dist/ai-dev-loop/index.d.cts +9 -0
- package/dist/ai-dev-loop/index.d.mts +9 -0
- package/dist/ai-dev-loop/index.mjs +10 -0
- package/dist/ai-dev-loop/live-mode.cjs +17 -0
- package/dist/ai-dev-loop/live-mode.d.cts +24 -0
- package/dist/ai-dev-loop/live-mode.d.cts.map +1 -0
- package/dist/ai-dev-loop/live-mode.d.mts +24 -0
- package/dist/ai-dev-loop/live-mode.d.mts.map +1 -0
- package/dist/ai-dev-loop/live-mode.mjs +17 -0
- package/dist/ai-dev-loop/live-mode.mjs.map +1 -0
- package/dist/ai-dev-loop/meta-tools.cjs +123 -0
- package/dist/ai-dev-loop/meta-tools.d.cts +24 -0
- package/dist/ai-dev-loop/meta-tools.d.cts.map +1 -0
- package/dist/ai-dev-loop/meta-tools.d.mts +24 -0
- package/dist/ai-dev-loop/meta-tools.d.mts.map +1 -0
- package/dist/ai-dev-loop/meta-tools.mjs +120 -0
- package/dist/ai-dev-loop/meta-tools.mjs.map +1 -0
- package/dist/ai-dev-loop/structured-runner.cjs +154 -0
- package/dist/ai-dev-loop/structured-runner.d.cts +12 -0
- package/dist/ai-dev-loop/structured-runner.d.cts.map +1 -0
- package/dist/ai-dev-loop/structured-runner.d.mts +12 -0
- package/dist/ai-dev-loop/structured-runner.d.mts.map +1 -0
- package/dist/ai-dev-loop/structured-runner.mjs +155 -0
- package/dist/ai-dev-loop/structured-runner.mjs.map +1 -0
- package/dist/ai-dev-loop/system-prompt.cjs +55 -0
- package/dist/ai-dev-loop/system-prompt.d.cts +20 -0
- package/dist/ai-dev-loop/system-prompt.d.cts.map +1 -0
- package/dist/ai-dev-loop/system-prompt.d.mts +20 -0
- package/dist/ai-dev-loop/system-prompt.d.mts.map +1 -0
- package/dist/ai-dev-loop/system-prompt.mjs +54 -0
- package/dist/ai-dev-loop/system-prompt.mjs.map +1 -0
- package/dist/ast.d.cts +140 -0
- package/dist/ast.d.cts.map +1 -0
- package/dist/ast.d.mts +140 -0
- package/dist/ast.d.mts.map +1 -0
- package/dist/compiler.cjs +802 -0
- package/dist/compiler.d.cts +103 -0
- package/dist/compiler.d.cts.map +1 -0
- package/dist/compiler.d.mts +103 -0
- package/dist/compiler.d.mts.map +1 -0
- package/dist/compiler.mjs +802 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.cjs +14 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/lexer.cjs +451 -0
- package/dist/lexer.d.cts +14 -0
- package/dist/lexer.d.cts.map +1 -0
- package/dist/lexer.d.mts +14 -0
- package/dist/lexer.d.mts.map +1 -0
- package/dist/lexer.mjs +451 -0
- package/dist/lexer.mjs.map +1 -0
- package/dist/parser.cjs +734 -0
- package/dist/parser.d.cts +40 -0
- package/dist/parser.d.cts.map +1 -0
- package/dist/parser.d.mts +40 -0
- package/dist/parser.d.mts.map +1 -0
- package/dist/parser.mjs +734 -0
- package/dist/parser.mjs.map +1 -0
- package/dist/reference.cjs +130 -0
- package/dist/reference.d.cts +11 -0
- package/dist/reference.d.cts.map +1 -0
- package/dist/reference.d.mts +11 -0
- package/dist/reference.d.mts.map +1 -0
- package/dist/reference.mjs +130 -0
- package/dist/reference.mjs.map +1 -0
- package/dist/template.cjs +85 -0
- package/dist/template.mjs +84 -0
- package/dist/template.mjs.map +1 -0
- package/dist/type-checker.cjs +582 -0
- package/dist/type-checker.d.cts +31 -0
- package/dist/type-checker.d.cts.map +1 -0
- package/dist/type-checker.d.mts +31 -0
- package/dist/type-checker.d.mts.map +1 -0
- package/dist/type-checker.mjs +573 -0
- package/dist/type-checker.mjs.map +1 -0
- package/package.json +29 -0
- package/src/ai-dev-loop/ash-run-result.test.ts +113 -0
- package/src/ai-dev-loop/ash-run-result.ts +46 -0
- package/src/ai-dev-loop/ash-typed-error.test.ts +136 -0
- package/src/ai-dev-loop/ash-typed-error.ts +50 -0
- package/src/ai-dev-loop/ash-validate.test.ts +54 -0
- package/src/ai-dev-loop/ash-validate.ts +34 -0
- package/src/ai-dev-loop/dev-loop.test.ts +364 -0
- package/src/ai-dev-loop/dev-loop.ts +156 -0
- package/src/ai-dev-loop/dry-run.test.ts +107 -0
- package/src/ai-dev-loop/e2e-multi-fix.test.ts +473 -0
- package/src/ai-dev-loop/e2e.test.ts +324 -0
- package/src/ai-dev-loop/index.ts +15 -0
- package/src/ai-dev-loop/invariants.test.ts +253 -0
- package/src/ai-dev-loop/live-mode.test.ts +63 -0
- package/src/ai-dev-loop/live-mode.ts +33 -0
- package/src/ai-dev-loop/meta-tools.test.ts +120 -0
- package/src/ai-dev-loop/meta-tools.ts +142 -0
- package/src/ai-dev-loop/structured-runner.test.ts +159 -0
- package/src/ai-dev-loop/structured-runner.ts +209 -0
- package/src/ai-dev-loop/system-prompt.test.ts +102 -0
- package/src/ai-dev-loop/system-prompt.ts +81 -0
- package/src/ast.ts +186 -0
- package/src/compiler.test.ts +2933 -0
- package/src/compiler.ts +1103 -0
- package/src/e2e.test.ts +552 -0
- package/src/index.ts +16 -0
- package/src/lexer.test.ts +538 -0
- package/src/lexer.ts +222 -0
- package/src/parser.test.ts +1024 -0
- package/src/parser.ts +835 -0
- package/src/reference.test.ts +166 -0
- package/src/reference.ts +125 -0
- package/src/template.test.ts +210 -0
- package/src/template.ts +139 -0
- package/src/type-checker.test.ts +1494 -0
- package/src/type-checker.ts +785 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +12 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ashRunToolDef,
|
|
4
|
+
ashValidateToolDef,
|
|
5
|
+
ashExplainErrorToolDef,
|
|
6
|
+
executeMetaTool,
|
|
7
|
+
} from "./meta-tools.js";
|
|
8
|
+
import type { WorldInterface, JobLogger } from "../compiler.js";
|
|
9
|
+
|
|
10
|
+
function makeCtx() {
|
|
11
|
+
return {
|
|
12
|
+
world: {
|
|
13
|
+
read: () => [],
|
|
14
|
+
write: () => {},
|
|
15
|
+
publish: () => {},
|
|
16
|
+
} as WorldInterface,
|
|
17
|
+
caps: new Set(["*"]),
|
|
18
|
+
logger: { log() {} } as JobLogger,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("Meta Tools", () => {
|
|
23
|
+
// ── Tool Definitions ───────────────────────────────────
|
|
24
|
+
|
|
25
|
+
it("ash.run tool definition has correct inputSchema", () => {
|
|
26
|
+
expect(ashRunToolDef.name).toBe("ash_run");
|
|
27
|
+
expect(ashRunToolDef.parameters).toBeDefined();
|
|
28
|
+
const params = ashRunToolDef.parameters as any;
|
|
29
|
+
expect(params.properties.script).toBeDefined();
|
|
30
|
+
expect(params.properties.mode).toBeDefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("ash.validate tool definition has correct inputSchema", () => {
|
|
34
|
+
expect(ashValidateToolDef.name).toBe("ash_validate");
|
|
35
|
+
const params = ashValidateToolDef.parameters as any;
|
|
36
|
+
expect(params.properties.script).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("ash.explain_error tool definition has correct inputSchema", () => {
|
|
40
|
+
expect(ashExplainErrorToolDef.name).toBe("ash_explain_error");
|
|
41
|
+
const params = ashExplainErrorToolDef.parameters as any;
|
|
42
|
+
expect(params.properties.error).toBeDefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── ash.run execution ──────────────────────────────────
|
|
46
|
+
|
|
47
|
+
it("ash.run execution with valid script → AshRunResult", async () => {
|
|
48
|
+
const ctx = makeCtx();
|
|
49
|
+
const result = await executeMetaTool("ash_run", {
|
|
50
|
+
script: 'job "test" { find /users }',
|
|
51
|
+
mode: "dry-run",
|
|
52
|
+
}, ctx);
|
|
53
|
+
expect(result).toBeDefined();
|
|
54
|
+
expect(result.status).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("ash.run default mode is dry-run", async () => {
|
|
58
|
+
const ctx = makeCtx();
|
|
59
|
+
// No mode specified → should default to dry-run
|
|
60
|
+
const result = await executeMetaTool("ash_run", {
|
|
61
|
+
script: 'output "hello"',
|
|
62
|
+
}, ctx);
|
|
63
|
+
expect(result).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── ash.validate execution ─────────────────────────────
|
|
67
|
+
|
|
68
|
+
it("ash.validate execution with valid script → ParseError[]", async () => {
|
|
69
|
+
const ctx = makeCtx();
|
|
70
|
+
const result = await executeMetaTool("ash_validate", {
|
|
71
|
+
script: 'job "test" { find /users }',
|
|
72
|
+
}, ctx);
|
|
73
|
+
expect(Array.isArray(result)).toBe(true);
|
|
74
|
+
expect(result).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── ash.explain_error ──────────────────────────────────
|
|
78
|
+
|
|
79
|
+
it("ash.explain_error with IntentDenied → human-readable explanation + suggestion", async () => {
|
|
80
|
+
const ctx = makeCtx();
|
|
81
|
+
const result = await executeMetaTool("ash_explain_error", {
|
|
82
|
+
error: { kind: "IntentDenied", invariant: "INV-2", message: "Action not allowed", suggestion: "Use dry-run" },
|
|
83
|
+
}, ctx);
|
|
84
|
+
expect(result.explanation).toContain("IntentDenied");
|
|
85
|
+
expect(result.suggestion).toBeDefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("ash.explain_error with ToolNotFound → lists available tools", async () => {
|
|
89
|
+
const ctx = makeCtx();
|
|
90
|
+
const result = await executeMetaTool("ash_explain_error", {
|
|
91
|
+
error: { kind: "ToolNotFound", name: "foo", available: ["find", "where"], message: "not found" },
|
|
92
|
+
}, ctx);
|
|
93
|
+
expect(result.explanation).toContain("ToolNotFound");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ash.explain_error with non-AshTypedError → generic explanation", async () => {
|
|
97
|
+
const ctx = makeCtx();
|
|
98
|
+
const result = await executeMetaTool("ash_explain_error", {
|
|
99
|
+
error: "some random string",
|
|
100
|
+
}, ctx);
|
|
101
|
+
expect(result.explanation).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── Bad Path ───────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
it("ash.run with missing script field → validation error", async () => {
|
|
107
|
+
const ctx = makeCtx();
|
|
108
|
+
await expect(executeMetaTool("ash_run", {}, ctx)).rejects.toThrow(/script/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Edge Cases ─────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
it("ash.run with no env → uses empty env", async () => {
|
|
114
|
+
const ctx = makeCtx();
|
|
115
|
+
const result = await executeMetaTool("ash_run", {
|
|
116
|
+
script: 'output "hello"',
|
|
117
|
+
}, ctx);
|
|
118
|
+
expect(result).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta Tools — LLM-callable tool definitions for ASH AI Dev Loop.
|
|
3
|
+
*
|
|
4
|
+
* These tools are Proc-internal, not registered in Discovery.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Tool definition type inline (avoid circular dependency with kernel)
|
|
8
|
+
interface LLMToolDef {
|
|
9
|
+
name: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
parameters?: unknown;
|
|
12
|
+
}
|
|
13
|
+
import type { JobContext } from "../compiler.js";
|
|
14
|
+
import { runStructured } from "./structured-runner.js";
|
|
15
|
+
import { ashValidate } from "./ash-validate.js";
|
|
16
|
+
import { isAshTypedError } from "./ash-typed-error.js";
|
|
17
|
+
|
|
18
|
+
export const ashRunToolDef: LLMToolDef = {
|
|
19
|
+
name: "ash_run",
|
|
20
|
+
description: "Execute an ASH script. Default mode is dry-run (no side effects).",
|
|
21
|
+
parameters: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
script: { type: "string", description: "ASH script source code" },
|
|
25
|
+
mode: { type: "string", enum: ["dry-run", "live"], description: "Execution mode. Default: dry-run" },
|
|
26
|
+
timeout_ms: { type: "number", description: "Timeout in milliseconds. Default: 30000" },
|
|
27
|
+
},
|
|
28
|
+
required: ["script"],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const ashValidateToolDef: LLMToolDef = {
|
|
33
|
+
name: "ash_validate",
|
|
34
|
+
description: "Validate an ASH script for syntax and type errors without executing it.",
|
|
35
|
+
parameters: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
script: { type: "string", description: "ASH script source code to validate" },
|
|
39
|
+
},
|
|
40
|
+
required: ["script"],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const ashExplainErrorToolDef: LLMToolDef = {
|
|
45
|
+
name: "ash_explain_error",
|
|
46
|
+
description: "Get a human-readable explanation of an ASH error with repair suggestions.",
|
|
47
|
+
parameters: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
error: { description: "The AshTypedError object to explain" },
|
|
51
|
+
},
|
|
52
|
+
required: ["error"],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const META_TOOL_DEFS = [ashRunToolDef, ashValidateToolDef, ashExplainErrorToolDef];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Execute a meta tool by name.
|
|
60
|
+
*/
|
|
61
|
+
export async function executeMetaTool(
|
|
62
|
+
name: string,
|
|
63
|
+
args: Record<string, unknown>,
|
|
64
|
+
ctx: JobContext,
|
|
65
|
+
): Promise<any> {
|
|
66
|
+
switch (name) {
|
|
67
|
+
case "ash_run": {
|
|
68
|
+
if (!args.script || typeof args.script !== "string") {
|
|
69
|
+
throw new Error("ash_run: script field is required and must be a string");
|
|
70
|
+
}
|
|
71
|
+
return runStructured(args.script as string, ctx, {
|
|
72
|
+
mode: (args.mode as "dry-run" | "live") ?? "dry-run",
|
|
73
|
+
timeout_ms: (args.timeout_ms as number) ?? 30000,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case "ash_validate": {
|
|
78
|
+
if (!args.script || typeof args.script !== "string") {
|
|
79
|
+
throw new Error("ash_validate: script field is required and must be a string");
|
|
80
|
+
}
|
|
81
|
+
return ashValidate(args.script as string);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
case "ash_explain_error": {
|
|
85
|
+
const error = args.error;
|
|
86
|
+
if (isAshTypedError(error)) {
|
|
87
|
+
return explainTypedError(error);
|
|
88
|
+
}
|
|
89
|
+
return { explanation: `Unknown error: ${JSON.stringify(error)}`, suggestion: "Check the error details." };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
default:
|
|
93
|
+
throw new Error(`Unknown meta tool: ${name}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function explainTypedError(error: any): { explanation: string; suggestion?: string } {
|
|
98
|
+
switch (error.kind) {
|
|
99
|
+
case "IntentDenied":
|
|
100
|
+
return {
|
|
101
|
+
explanation: `IntentDenied: ${error.message}. Violated invariant: ${error.invariant}.`,
|
|
102
|
+
suggestion: error.suggestion ?? "Check the action permissions.",
|
|
103
|
+
};
|
|
104
|
+
case "CapabilityMissing":
|
|
105
|
+
return {
|
|
106
|
+
explanation: `CapabilityMissing: Required capability "${error.capability}" is not available.`,
|
|
107
|
+
suggestion: "Request the capability or use a different approach.",
|
|
108
|
+
};
|
|
109
|
+
case "ToolNotFound":
|
|
110
|
+
return {
|
|
111
|
+
explanation: `ToolNotFound: Tool "${error.name}" not found. Available: [${error.available?.join(", ")}].`,
|
|
112
|
+
suggestion: "Use one of the available tools.",
|
|
113
|
+
};
|
|
114
|
+
case "ValidationFailed":
|
|
115
|
+
return {
|
|
116
|
+
explanation: `ValidationFailed: Field "${error.field}" expected ${error.expected}, got ${error.got}.`,
|
|
117
|
+
suggestion: "Fix the field value to match the expected type.",
|
|
118
|
+
};
|
|
119
|
+
case "BudgetExceeded":
|
|
120
|
+
return {
|
|
121
|
+
explanation: `BudgetExceeded: Device "${error.device}" used ${error.used}/${error.limit}.`,
|
|
122
|
+
suggestion: "Reduce token usage or wait for budget reset.",
|
|
123
|
+
};
|
|
124
|
+
case "Timeout":
|
|
125
|
+
return {
|
|
126
|
+
explanation: `Timeout: Step "${error.step}" exceeded ${error.limit_ms}ms limit.`,
|
|
127
|
+
suggestion: "Simplify the query or increase the timeout.",
|
|
128
|
+
};
|
|
129
|
+
case "ParseError":
|
|
130
|
+
return {
|
|
131
|
+
explanation: `ParseError: ${error.message}${error.line ? ` at line ${error.line}` : ""}.`,
|
|
132
|
+
suggestion: "Fix the syntax error in the ASH script.",
|
|
133
|
+
};
|
|
134
|
+
case "RuntimeError":
|
|
135
|
+
return {
|
|
136
|
+
explanation: `RuntimeError: ${error.message}.`,
|
|
137
|
+
suggestion: "Check the runtime context and data.",
|
|
138
|
+
};
|
|
139
|
+
default:
|
|
140
|
+
return { explanation: `Error: ${error.message ?? JSON.stringify(error)}` };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { runStructured } from "./structured-runner.js";
|
|
3
|
+
import type { WorldInterface, JobLogger } from "../compiler.js";
|
|
4
|
+
import { isAshRunSuccess, isAshRunFailure } from "./ash-run-result.js";
|
|
5
|
+
|
|
6
|
+
function makeWorld(data: Record<string, unknown[]> = {}): WorldInterface & {
|
|
7
|
+
written: Record<string, unknown[]>;
|
|
8
|
+
published: Record<string, unknown[]>;
|
|
9
|
+
} {
|
|
10
|
+
const written: Record<string, unknown[]> = {};
|
|
11
|
+
const published: Record<string, unknown[]> = {};
|
|
12
|
+
return {
|
|
13
|
+
read(path: string) { return data[path] ?? []; },
|
|
14
|
+
write(path: string, records: unknown[]) { written[path] = records; },
|
|
15
|
+
publish(topic: string, records: unknown[]) { published[topic] = records; },
|
|
16
|
+
written,
|
|
17
|
+
published,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeLogger(): JobLogger {
|
|
22
|
+
return { log() {} };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeCtx(data: Record<string, unknown[]> = {}) {
|
|
26
|
+
return {
|
|
27
|
+
world: makeWorld(data),
|
|
28
|
+
caps: new Set(["*"]),
|
|
29
|
+
logger: makeLogger(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Structured Runner", () => {
|
|
34
|
+
// ── Happy Path ─────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
it("run valid script → AshRunSuccess with correct step count", async () => {
|
|
37
|
+
const ctx = makeCtx({ "/users": [{ name: "Alice" }, { name: "Bob" }] });
|
|
38
|
+
const result = await runStructured('job "test" { find /users }', ctx);
|
|
39
|
+
expect(isAshRunSuccess(result)).toBe(true);
|
|
40
|
+
if (isAshRunSuccess(result)) {
|
|
41
|
+
expect(result.steps.length).toBeGreaterThanOrEqual(1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("run script with find + where + save → 3 steps in result", async () => {
|
|
46
|
+
const ctx = makeCtx({ "/users": [{ name: "Alice", age: 30 }, { name: "Bob", age: 20 }] });
|
|
47
|
+
const result = await runStructured(
|
|
48
|
+
'job "test" { find /users | where age > 25 | save /seniors }',
|
|
49
|
+
ctx,
|
|
50
|
+
);
|
|
51
|
+
expect(isAshRunSuccess(result)).toBe(true);
|
|
52
|
+
if (isAshRunSuccess(result)) {
|
|
53
|
+
expect(result.steps).toHaveLength(3);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("each step has correct command name extracted", async () => {
|
|
58
|
+
const ctx = makeCtx({ "/data": [{ x: 1 }] });
|
|
59
|
+
const result = await runStructured('job "test" { find /data | map x }', ctx);
|
|
60
|
+
if (isAshRunSuccess(result)) {
|
|
61
|
+
expect(result.steps[0].command).toBe("find");
|
|
62
|
+
expect(result.steps[1].command).toBe("map");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("duration_ms is positive number", async () => {
|
|
67
|
+
const ctx = makeCtx({ "/data": [{ x: 1 }] });
|
|
68
|
+
const result = await runStructured('job "test" { find /data }', ctx);
|
|
69
|
+
expect(result.duration_ms).toBeGreaterThanOrEqual(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── Bad Path ───────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
it("run script with syntax error → AshRunFailure with ParseError", async () => {
|
|
75
|
+
const ctx = makeCtx();
|
|
76
|
+
const result = await runStructured('job "test" { find }', ctx);
|
|
77
|
+
// find without path is a syntax error
|
|
78
|
+
expect(isAshRunFailure(result)).toBe(true);
|
|
79
|
+
if (isAshRunFailure(result)) {
|
|
80
|
+
expect(result.failedAt.kind).toBe("ParseError");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── Edge Cases ─────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
it("empty script → AshRunSuccess with 0 steps", async () => {
|
|
87
|
+
const ctx = makeCtx();
|
|
88
|
+
const result = await runStructured("", ctx);
|
|
89
|
+
expect(isAshRunSuccess(result)).toBe(true);
|
|
90
|
+
if (isAshRunSuccess(result)) {
|
|
91
|
+
expect(result.steps).toHaveLength(0);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("script with only output → AshRunSuccess", async () => {
|
|
96
|
+
const ctx = makeCtx();
|
|
97
|
+
const result = await runStructured('output "hello"', ctx);
|
|
98
|
+
expect(isAshRunSuccess(result)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── Security ───────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
it("raw error messages don't leak internal paths", async () => {
|
|
104
|
+
const ctx = makeCtx();
|
|
105
|
+
const result = await runStructured('job "test" { find }', ctx);
|
|
106
|
+
if (isAshRunFailure(result)) {
|
|
107
|
+
expect(result.failedAt.message).not.toContain("/Users/");
|
|
108
|
+
expect(result.failedAt.message).not.toContain("node_modules");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── timeout_ms ─────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
it("timeout_ms prevents side effects after timeout (cooperative cancellation)", async () => {
|
|
115
|
+
// Create a world where read takes 50ms, then save happens after
|
|
116
|
+
// If timeout fires at 10ms, save should NOT execute
|
|
117
|
+
const slowWorld: WorldInterface & { written: Record<string, unknown[]>; published: Record<string, unknown[]> } = {
|
|
118
|
+
async read() {
|
|
119
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
120
|
+
return [{ x: 1 }];
|
|
121
|
+
},
|
|
122
|
+
write(path: string, records: unknown[]) { slowWorld.written[path] = records; },
|
|
123
|
+
publish(topic: string, records: unknown[]) { slowWorld.published[topic] = records; },
|
|
124
|
+
written: {},
|
|
125
|
+
published: {},
|
|
126
|
+
};
|
|
127
|
+
const ctx = { world: slowWorld, caps: new Set(["*"]), logger: makeLogger() };
|
|
128
|
+
const result = await runStructured(
|
|
129
|
+
'job "test" { find /data | save /output }',
|
|
130
|
+
ctx,
|
|
131
|
+
{ timeout_ms: 10 },
|
|
132
|
+
);
|
|
133
|
+
expect(isAshRunFailure(result)).toBe(true);
|
|
134
|
+
// After timeout, the write side effect should NOT have happened
|
|
135
|
+
// Wait a bit to ensure any lingering execution would have completed
|
|
136
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
137
|
+
expect(slowWorld.written["/output"]).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("timeout_ms triggers error when execution exceeds limit", async () => {
|
|
141
|
+
// Create a world with a slow read that takes ~200ms
|
|
142
|
+
const slowWorld: WorldInterface & { written: Record<string, unknown[]>; published: Record<string, unknown[]> } = {
|
|
143
|
+
async read() {
|
|
144
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
145
|
+
return [{ x: 1 }];
|
|
146
|
+
},
|
|
147
|
+
write(path: string, records: unknown[]) { slowWorld.written[path] = records; },
|
|
148
|
+
publish(topic: string, records: unknown[]) { slowWorld.published[topic] = records; },
|
|
149
|
+
written: {},
|
|
150
|
+
published: {},
|
|
151
|
+
};
|
|
152
|
+
const ctx = { world: slowWorld, caps: new Set(["*"]), logger: makeLogger() };
|
|
153
|
+
const result = await runStructured('job "test" { find /data }', ctx, { timeout_ms: 10 });
|
|
154
|
+
expect(isAshRunFailure(result)).toBe(true);
|
|
155
|
+
if (isAshRunFailure(result)) {
|
|
156
|
+
expect(result.failedAt.message).toContain("imeout");
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured Runner — Wraps ASH compileSource() + execute() with step tracking.
|
|
3
|
+
*
|
|
4
|
+
* Produces AshRunResult from raw ASH execution.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { compileSource, type JobContext, type WorldInterface } from "../compiler.js";
|
|
8
|
+
import type { AshRunResult, AshStepResult } from "./ash-run-result.js";
|
|
9
|
+
import { fromJobError } from "./ash-typed-error.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps a WorldInterface to intercept all writes in dry-run mode.
|
|
13
|
+
* Reads pass through normally; write/publish are silently absorbed.
|
|
14
|
+
*/
|
|
15
|
+
function createDryRunWorld(real: WorldInterface): WorldInterface {
|
|
16
|
+
return {
|
|
17
|
+
read: (path, query) => real.read(path, query),
|
|
18
|
+
write: (_path, _data) => { /* no-op in dry-run */ },
|
|
19
|
+
publish: (_topic, _data) => { /* no-op in dry-run */ },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wraps a WorldInterface to check an AbortSignal before every operation.
|
|
25
|
+
* Once aborted, all operations throw immediately — preventing side effects
|
|
26
|
+
* from continuing after a timeout.
|
|
27
|
+
*/
|
|
28
|
+
function createCancellableWorld(real: WorldInterface, signal: AbortSignal): WorldInterface {
|
|
29
|
+
function checkAborted() {
|
|
30
|
+
if (signal.aborted) throw new Error("Execution cancelled: timeout");
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
read(path, query) {
|
|
34
|
+
checkAborted();
|
|
35
|
+
return real.read(path, query);
|
|
36
|
+
},
|
|
37
|
+
write(path, data) {
|
|
38
|
+
checkAborted();
|
|
39
|
+
real.write(path, data);
|
|
40
|
+
},
|
|
41
|
+
publish(topic, data) {
|
|
42
|
+
checkAborted();
|
|
43
|
+
real.publish(topic, data);
|
|
44
|
+
},
|
|
45
|
+
...(real.exec ? {
|
|
46
|
+
exec(path: string, input: unknown[], params?: Record<string, unknown>) {
|
|
47
|
+
checkAborted();
|
|
48
|
+
return real.exec!(path, input, params);
|
|
49
|
+
},
|
|
50
|
+
} : {}),
|
|
51
|
+
...(real.input ? {
|
|
52
|
+
input(prompt: string) {
|
|
53
|
+
checkAborted();
|
|
54
|
+
return real.input!(prompt);
|
|
55
|
+
},
|
|
56
|
+
} : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RunOptions {
|
|
61
|
+
mode?: "dry-run" | "live";
|
|
62
|
+
timeout_ms?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runStructured(
|
|
66
|
+
source: string,
|
|
67
|
+
ctx: JobContext,
|
|
68
|
+
options?: RunOptions,
|
|
69
|
+
): Promise<AshRunResult> {
|
|
70
|
+
const startTime = performance.now();
|
|
71
|
+
|
|
72
|
+
// Empty source → success with no steps
|
|
73
|
+
if (!source.trim()) {
|
|
74
|
+
return {
|
|
75
|
+
status: "ok",
|
|
76
|
+
steps: [],
|
|
77
|
+
output: [],
|
|
78
|
+
duration_ms: Math.round(performance.now() - startTime),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Compile
|
|
83
|
+
const compileResult = compileSource(source);
|
|
84
|
+
|
|
85
|
+
// Check for compile errors
|
|
86
|
+
const errors = compileResult.diagnostics.filter((d) => d.severity !== "warning");
|
|
87
|
+
if (errors.length > 0 || !compileResult.program) {
|
|
88
|
+
const firstError = errors[0] ?? compileResult.diagnostics[0];
|
|
89
|
+
return {
|
|
90
|
+
status: "error",
|
|
91
|
+
steps: [],
|
|
92
|
+
failedAt: {
|
|
93
|
+
kind: "ParseError",
|
|
94
|
+
line: firstError?.line,
|
|
95
|
+
message: firstError?.message ?? "Compilation failed",
|
|
96
|
+
},
|
|
97
|
+
duration_ms: Math.round(performance.now() - startTime),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const program = compileResult.program;
|
|
102
|
+
const allSteps: AshStepResult[] = [];
|
|
103
|
+
|
|
104
|
+
// Apply dry-run world wrapper if in dry-run mode
|
|
105
|
+
const mode = options?.mode ?? "live";
|
|
106
|
+
let execCtx: JobContext = mode === "dry-run"
|
|
107
|
+
? { ...ctx, world: createDryRunWorld(ctx.world) }
|
|
108
|
+
: ctx;
|
|
109
|
+
|
|
110
|
+
// Wrap execution with timeout if specified
|
|
111
|
+
const timeoutMs = options?.timeout_ms;
|
|
112
|
+
if (timeoutMs != null && timeoutMs > 0) {
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
// Wrap world with cancellation guard so operations throw after abort
|
|
115
|
+
execCtx = { ...execCtx, world: createCancellableWorld(execCtx.world, controller.signal) };
|
|
116
|
+
|
|
117
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
118
|
+
return Promise.race([
|
|
119
|
+
executeUnits(program, execCtx, allSteps, startTime),
|
|
120
|
+
new Promise<AshRunResult>((_, reject) => {
|
|
121
|
+
controller.signal.addEventListener("abort", () =>
|
|
122
|
+
reject(new Error(`Timeout: execution exceeded ${timeoutMs}ms`)),
|
|
123
|
+
);
|
|
124
|
+
}),
|
|
125
|
+
]).catch((err: any) => ({
|
|
126
|
+
status: "error" as const,
|
|
127
|
+
steps: allSteps,
|
|
128
|
+
failedAt: fromJobError(err.message ?? String(err)),
|
|
129
|
+
duration_ms: Math.round(performance.now() - startTime),
|
|
130
|
+
})).finally(() => clearTimeout(timer));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return executeUnits(program, execCtx, allSteps, startTime);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function executeUnits(
|
|
137
|
+
program: NonNullable<ReturnType<typeof compileSource>["program"]>,
|
|
138
|
+
execCtx: JobContext,
|
|
139
|
+
allSteps: AshStepResult[],
|
|
140
|
+
startTime: number,
|
|
141
|
+
): Promise<AshRunResult> {
|
|
142
|
+
// Execute all units (skip route-target-only jobs)
|
|
143
|
+
for (const unit of program.units) {
|
|
144
|
+
if (unit.kind === "output") {
|
|
145
|
+
await unit.execute(execCtx);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Skip route-target-only jobs — they execute only when dispatched by route
|
|
150
|
+
if (program.routeTargets?.has(unit.name)) continue;
|
|
151
|
+
|
|
152
|
+
// Job execution
|
|
153
|
+
try {
|
|
154
|
+
const jobResult = await unit.execute(execCtx);
|
|
155
|
+
|
|
156
|
+
// Extract step metrics from JobReport if available
|
|
157
|
+
const report = jobResult as any;
|
|
158
|
+
if (report.stages && Array.isArray(report.stages)) {
|
|
159
|
+
for (const stage of report.stages) {
|
|
160
|
+
allSteps.push({
|
|
161
|
+
step: stage.index ?? allSteps.length,
|
|
162
|
+
command: stage.name ?? "unknown",
|
|
163
|
+
status: stage.error ? "error" : "ok",
|
|
164
|
+
duration_ms: stage.durationMs ?? 0,
|
|
165
|
+
output: [],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// Fallback: single step from job
|
|
170
|
+
allSteps.push({
|
|
171
|
+
step: allSteps.length,
|
|
172
|
+
command: unit.name ?? "job",
|
|
173
|
+
status: jobResult.status === "ok" ? "ok" : "error",
|
|
174
|
+
duration_ms: 0,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (jobResult.status === "error" && jobResult.errors.length > 0) {
|
|
179
|
+
return {
|
|
180
|
+
status: "error",
|
|
181
|
+
steps: allSteps,
|
|
182
|
+
failedAt: fromJobError(jobResult.errors[0]),
|
|
183
|
+
duration_ms: Math.round(performance.now() - startTime),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
} catch (err: any) {
|
|
187
|
+
allSteps.push({
|
|
188
|
+
step: allSteps.length,
|
|
189
|
+
command: unit.name ?? "job",
|
|
190
|
+
status: "error",
|
|
191
|
+
duration_ms: 0,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
status: "error",
|
|
196
|
+
steps: allSteps,
|
|
197
|
+
failedAt: fromJobError(err.message ?? String(err)),
|
|
198
|
+
duration_ms: Math.round(performance.now() - startTime),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
status: "ok",
|
|
205
|
+
steps: allSteps,
|
|
206
|
+
output: [],
|
|
207
|
+
duration_ms: Math.round(performance.now() - startTime),
|
|
208
|
+
};
|
|
209
|
+
}
|