@mhingston5/lasso 0.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 +707 -0
- package/docs/agent-wrangling.png +0 -0
- package/package.json +26 -0
- package/src/capabilities/matcher.ts +25 -0
- package/src/capabilities/registry.ts +103 -0
- package/src/capabilities/types.ts +15 -0
- package/src/cir/lower.ts +253 -0
- package/src/cir/optimize.ts +251 -0
- package/src/cir/types.ts +131 -0
- package/src/cir/validate.ts +265 -0
- package/src/compiler/compile.ts +601 -0
- package/src/compiler/feedback.ts +471 -0
- package/src/compiler/runtime-helpers.ts +455 -0
- package/src/composition/chain.ts +58 -0
- package/src/composition/conditional.ts +76 -0
- package/src/composition/parallel.ts +75 -0
- package/src/composition/types.ts +105 -0
- package/src/environment/analyzer.ts +56 -0
- package/src/environment/discovery.ts +179 -0
- package/src/environment/types.ts +68 -0
- package/src/failures/classifiers.ts +134 -0
- package/src/failures/generator.ts +421 -0
- package/src/failures/map-reference-failures.ts +23 -0
- package/src/failures/ontology.ts +210 -0
- package/src/failures/recovery.ts +214 -0
- package/src/failures/types.ts +14 -0
- package/src/index.ts +67 -0
- package/src/memory/advisor.ts +132 -0
- package/src/memory/extractor.ts +166 -0
- package/src/memory/store.ts +107 -0
- package/src/memory/types.ts +53 -0
- package/src/metaharness/engine.ts +256 -0
- package/src/metaharness/predictor.ts +168 -0
- package/src/metaharness/types.ts +40 -0
- package/src/mutation/derive.ts +308 -0
- package/src/mutation/diff.ts +52 -0
- package/src/mutation/engine.ts +256 -0
- package/src/mutation/types.ts +84 -0
- package/src/pi/command-input.ts +209 -0
- package/src/pi/commands.ts +351 -0
- package/src/pi/extension.ts +16 -0
- package/src/planner/synthesize.ts +83 -0
- package/src/planner/template-rules.ts +183 -0
- package/src/planner/types.ts +42 -0
- package/src/reference/catalog.ts +128 -0
- package/src/reference/patch-validation-strategies.ts +170 -0
- package/src/reference/patch-validation.ts +174 -0
- package/src/reference/pr-review-merge.ts +155 -0
- package/src/reference/strategies.ts +126 -0
- package/src/reference/types.ts +33 -0
- package/src/replanner/risk-rules.ts +161 -0
- package/src/replanner/runtime.ts +308 -0
- package/src/replanner/synthesize.ts +619 -0
- package/src/replanner/types.ts +73 -0
- package/src/spec/schema.ts +254 -0
- package/src/spec/types.ts +319 -0
- package/src/spec/validate.ts +296 -0
- package/src/state/snapshots.ts +43 -0
- package/src/state/types.ts +12 -0
- package/src/synthesis/graph-builder.ts +267 -0
- package/src/synthesis/harness-builder.ts +113 -0
- package/src/synthesis/intent-ir.ts +63 -0
- package/src/synthesis/policy-builder.ts +320 -0
- package/src/synthesis/risk-analyzer.ts +182 -0
- package/src/synthesis/skill-parser.ts +441 -0
- package/src/verification/engine.ts +230 -0
- package/src/versioning/file-store.ts +103 -0
- package/src/versioning/history.ts +43 -0
- package/src/versioning/store.ts +16 -0
- package/src/versioning/types.ts +31 -0
- package/test/capabilities/matcher.test.ts +67 -0
- package/test/capabilities/registry.test.ts +136 -0
- package/test/capabilities/synthesis.test.ts +264 -0
- package/test/cir/lower.test.ts +417 -0
- package/test/cir/optimize.test.ts +266 -0
- package/test/cir/validate.test.ts +368 -0
- package/test/compiler/adaptive-runtime.test.ts +157 -0
- package/test/compiler/compile.test.ts +1198 -0
- package/test/compiler/feedback.test.ts +784 -0
- package/test/compiler/guardrails.test.ts +191 -0
- package/test/compiler/trace.test.ts +404 -0
- package/test/composition/chain.test.ts +328 -0
- package/test/composition/conditional.test.ts +241 -0
- package/test/composition/parallel.test.ts +215 -0
- package/test/environment/analyzer.test.ts +204 -0
- package/test/environment/discovery.test.ts +149 -0
- package/test/failures/classifiers.test.ts +287 -0
- package/test/failures/generator.test.ts +203 -0
- package/test/failures/ontology.test.ts +439 -0
- package/test/failures/recovery.test.ts +300 -0
- package/test/helpers/createFixtureRepo.ts +84 -0
- package/test/helpers/createPatchValidationFixture.ts +144 -0
- package/test/helpers/runCompiledWorkflow.ts +208 -0
- package/test/memory/advisor.test.ts +332 -0
- package/test/memory/extractor.test.ts +295 -0
- package/test/memory/store.test.ts +244 -0
- package/test/metaharness/engine.test.ts +575 -0
- package/test/metaharness/predictor.test.ts +436 -0
- package/test/mutation/derive-failure.test.ts +209 -0
- package/test/mutation/engine.test.ts +622 -0
- package/test/package-smoke.test.ts +29 -0
- package/test/pi/command-input.test.ts +153 -0
- package/test/pi/commands.test.ts +623 -0
- package/test/planner/classify-template.test.ts +32 -0
- package/test/planner/synthesize.test.ts +901 -0
- package/test/reference/PatchValidation.failures.test.ts +137 -0
- package/test/reference/PatchValidation.test.ts +326 -0
- package/test/reference/PrReviewMerge.failures.test.ts +121 -0
- package/test/reference/PrReviewMerge.test.ts +55 -0
- package/test/reference/catalog-open.test.ts +70 -0
- package/test/replanner/runtime.test.ts +207 -0
- package/test/replanner/synthesize.test.ts +303 -0
- package/test/spec/validate.test.ts +1056 -0
- package/test/state/snapshots.test.ts +264 -0
- package/test/synthesis/custom-workflow.test.ts +264 -0
- package/test/synthesis/graph-builder.test.ts +370 -0
- package/test/synthesis/harness-builder.test.ts +128 -0
- package/test/synthesis/policy-builder.test.ts +149 -0
- package/test/synthesis/risk-analyzer.test.ts +230 -0
- package/test/synthesis/skill-parser.test.ts +796 -0
- package/test/verification/engine.test.ts +509 -0
- package/test/versioning/history.test.ts +144 -0
- package/test/versioning/store.test.ts +254 -0
- package/vitest.config.ts +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mhingston5/lasso",
|
|
3
|
+
"description": "Lasso is a local-first workflow compiler for pi-duroxide.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"author": "Mark Hingston",
|
|
6
|
+
"repository": "https://github.com/mhingston/lasso",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "echo 'nothing to build'",
|
|
11
|
+
"check": "echo 'nothing to check'",
|
|
12
|
+
"test": "vitest --run"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"ajv": "^8.17.1",
|
|
16
|
+
"pi-duroxide": "0.2.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"vitest": "^3.2.4"
|
|
20
|
+
},
|
|
21
|
+
"pi": {
|
|
22
|
+
"extensions": [
|
|
23
|
+
"./src/index.ts"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Capability, CapabilityRegistry } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface CapabilityMatchResult {
|
|
4
|
+
matched: Capability[];
|
|
5
|
+
missing: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function matchCapabilities(
|
|
9
|
+
requiredTools: string[],
|
|
10
|
+
registry: CapabilityRegistry
|
|
11
|
+
): CapabilityMatchResult {
|
|
12
|
+
const matched: Capability[] = [];
|
|
13
|
+
const missing: string[] = [];
|
|
14
|
+
|
|
15
|
+
for (const toolId of requiredTools) {
|
|
16
|
+
const capability = registry.getCapability(toolId);
|
|
17
|
+
if (capability) {
|
|
18
|
+
matched.push(capability);
|
|
19
|
+
} else {
|
|
20
|
+
missing.push(toolId);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { matched, missing };
|
|
25
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Capability, CapabilityRegistry } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export class DefaultCapabilityRegistry implements CapabilityRegistry {
|
|
4
|
+
private capabilities = new Map<string, Capability>();
|
|
5
|
+
|
|
6
|
+
constructor() {
|
|
7
|
+
this.registerDefaults();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
getCapabilities(): Capability[] {
|
|
11
|
+
return Array.from(this.capabilities.values());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
hasCapability(id: string): boolean {
|
|
15
|
+
return this.capabilities.has(id);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getCapability(id: string): Capability | undefined {
|
|
19
|
+
return this.capabilities.get(id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
registerCapability(capability: Capability): void {
|
|
23
|
+
this.capabilities.set(capability.id, capability);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private registerDefaults(): void {
|
|
27
|
+
this.registerCapability({
|
|
28
|
+
id: "bash",
|
|
29
|
+
kind: "tool",
|
|
30
|
+
name: "Bash Shell",
|
|
31
|
+
prerequisites: [],
|
|
32
|
+
risks: [
|
|
33
|
+
"Executes arbitrary system commands",
|
|
34
|
+
"Can modify filesystem",
|
|
35
|
+
"May have elevated permissions"
|
|
36
|
+
],
|
|
37
|
+
verification: [
|
|
38
|
+
"Verify command output",
|
|
39
|
+
"Check exit codes"
|
|
40
|
+
]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
this.registerCapability({
|
|
44
|
+
id: "git",
|
|
45
|
+
kind: "tool",
|
|
46
|
+
name: "Git Version Control",
|
|
47
|
+
prerequisites: ["bash"],
|
|
48
|
+
risks: [
|
|
49
|
+
"Modifies repository history",
|
|
50
|
+
"Can overwrite remote changes"
|
|
51
|
+
],
|
|
52
|
+
verification: [
|
|
53
|
+
"Verify branch state",
|
|
54
|
+
"Check commit status"
|
|
55
|
+
]
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this.registerCapability({
|
|
59
|
+
id: "node",
|
|
60
|
+
kind: "tool",
|
|
61
|
+
name: "Node.js Runtime",
|
|
62
|
+
prerequisites: ["bash"],
|
|
63
|
+
risks: [
|
|
64
|
+
"Executes arbitrary JavaScript",
|
|
65
|
+
"Network access capability"
|
|
66
|
+
],
|
|
67
|
+
verification: [
|
|
68
|
+
"Verify Node.js version",
|
|
69
|
+
"Check package installation"
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.registerCapability({
|
|
74
|
+
id: "llm-review",
|
|
75
|
+
kind: "llm",
|
|
76
|
+
name: "LLM Code Review",
|
|
77
|
+
prerequisites: [],
|
|
78
|
+
risks: [
|
|
79
|
+
"May produce incorrect suggestions",
|
|
80
|
+
"Non-deterministic outputs"
|
|
81
|
+
],
|
|
82
|
+
verification: [
|
|
83
|
+
"Verify review completeness",
|
|
84
|
+
"Check for hallucinated references"
|
|
85
|
+
]
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.registerCapability({
|
|
89
|
+
id: "human-approval",
|
|
90
|
+
kind: "human",
|
|
91
|
+
name: "Human Approval Gate",
|
|
92
|
+
prerequisites: [],
|
|
93
|
+
risks: [
|
|
94
|
+
"Introduces workflow delay",
|
|
95
|
+
"Requires human availability"
|
|
96
|
+
],
|
|
97
|
+
verification: [
|
|
98
|
+
"Confirm approval received",
|
|
99
|
+
"Verify approver identity"
|
|
100
|
+
]
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface Capability {
|
|
2
|
+
id: string;
|
|
3
|
+
kind: "tool" | "llm" | "service" | "human";
|
|
4
|
+
name: string;
|
|
5
|
+
prerequisites: string[];
|
|
6
|
+
risks: string[];
|
|
7
|
+
verification: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CapabilityRegistry {
|
|
11
|
+
getCapabilities(): Capability[];
|
|
12
|
+
hasCapability(id: string): boolean;
|
|
13
|
+
getCapability(id: string): Capability | undefined;
|
|
14
|
+
registerCapability(capability: Capability): void;
|
|
15
|
+
}
|
package/src/cir/lower.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { validateHarnessSpec } from "../spec/validate.js";
|
|
2
|
+
import type { ExecutionPolicy, HarnessSpec, TaskNode, VerificationRule } from "../spec/types.js";
|
|
3
|
+
import type {
|
|
4
|
+
CirExecutionPolicy,
|
|
5
|
+
CirFailureRoutingHint,
|
|
6
|
+
CirGlobalPolicies,
|
|
7
|
+
CirNode,
|
|
8
|
+
CirTransition,
|
|
9
|
+
CirVerificationHook,
|
|
10
|
+
CirWorkflow,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
export function lowerHarnessSpecToCir(spec: HarnessSpec): CirWorkflow {
|
|
14
|
+
const validation = validateHarnessSpec(spec);
|
|
15
|
+
if (!validation.valid) {
|
|
16
|
+
throw new Error(`HarnessSpec validation failed:\n- ${validation.errors.join("\n- ")}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const conditionNodeIds = new Set(
|
|
20
|
+
spec.graph.nodes.filter((node): node is Extract<TaskNode, { kind: "condition" }> => node.kind === "condition").map(node => node.id),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
for (const edge of spec.graph.edges) {
|
|
24
|
+
if (conditionNodeIds.has(edge.from)) {
|
|
25
|
+
throw new Error(`Condition node "${edge.from}" cannot declare outgoing graph edges; use thenNodeId/elseNodeId instead`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const transitions = [
|
|
30
|
+
...lowerGraphEdges(spec),
|
|
31
|
+
...lowerConditionTransitions(spec),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const nodes = spec.graph.nodes.map((node, index) => lowerNode(spec, node, index, transitions));
|
|
35
|
+
const globalPolicies = lowerGlobalPolicies(spec);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: spec.name,
|
|
39
|
+
entryNodeId: spec.graph.entryNodeId,
|
|
40
|
+
nodes,
|
|
41
|
+
transitions,
|
|
42
|
+
...(globalPolicies ? { globalPolicies } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function lowerGraphEdges(spec: HarnessSpec): CirTransition[] {
|
|
47
|
+
return spec.graph.edges.map((edge, index) => ({
|
|
48
|
+
from: edge.from,
|
|
49
|
+
to: edge.to,
|
|
50
|
+
when: "success",
|
|
51
|
+
source: {
|
|
52
|
+
kind: "graph-edge",
|
|
53
|
+
specPath: `graph.edges[${index}]`,
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function lowerConditionTransitions(spec: HarnessSpec): CirTransition[] {
|
|
59
|
+
return spec.graph.nodes.flatMap((node, index) => {
|
|
60
|
+
if (node.kind !== "condition") {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
from: node.id,
|
|
67
|
+
to: node.thenNodeId,
|
|
68
|
+
when: "condition-true" as const,
|
|
69
|
+
source: {
|
|
70
|
+
kind: "condition-then" as const,
|
|
71
|
+
specNodeId: node.id,
|
|
72
|
+
specPath: `graph.nodes[${index}].thenNodeId`,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
from: node.id,
|
|
77
|
+
to: node.elseNodeId,
|
|
78
|
+
when: "condition-false" as const,
|
|
79
|
+
source: {
|
|
80
|
+
kind: "condition-else" as const,
|
|
81
|
+
specNodeId: node.id,
|
|
82
|
+
specPath: `graph.nodes[${index}].elseNodeId`,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function lowerNode(spec: HarnessSpec, node: TaskNode, index: number, transitions: CirTransition[]): CirNode {
|
|
90
|
+
const { execution, failureRouting } = resolveNodeExecution(spec.executionPolicy, node.executionPolicy);
|
|
91
|
+
const verification = lowerVerification(node.verificationPolicy?.rules);
|
|
92
|
+
const outgoingCount = transitions.filter(transition => transition.from === node.id).length;
|
|
93
|
+
const resolvedHumanTimeout = node.kind === "human" ? node.timeout ?? spec.humanPolicy?.defaultTimeout : undefined;
|
|
94
|
+
const baseNode = {
|
|
95
|
+
id: node.id,
|
|
96
|
+
kind: node.kind,
|
|
97
|
+
source: {
|
|
98
|
+
specNodeId: node.id,
|
|
99
|
+
specNodeKind: node.kind,
|
|
100
|
+
specPath: `graph.nodes[${index}]`,
|
|
101
|
+
...(node.label ? { label: node.label } : {}),
|
|
102
|
+
},
|
|
103
|
+
...(execution ? { execution } : {}),
|
|
104
|
+
...(node.retryPolicy ? { retry: cloneRetryPolicy(node.retryPolicy) } : {}),
|
|
105
|
+
...(verification ? { verification } : {}),
|
|
106
|
+
...(failureRouting ? { failureRouting } : {}),
|
|
107
|
+
terminal: outgoingCount === 0,
|
|
108
|
+
} as const;
|
|
109
|
+
|
|
110
|
+
switch (node.kind) {
|
|
111
|
+
case "tool":
|
|
112
|
+
return {
|
|
113
|
+
...baseNode,
|
|
114
|
+
kind: "tool",
|
|
115
|
+
action: {
|
|
116
|
+
tool: node.tool,
|
|
117
|
+
args: [...node.args],
|
|
118
|
+
...(node.env ? { env: { ...node.env } } : {}),
|
|
119
|
+
...(node.cwd ? { cwd: node.cwd } : {}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
case "llm":
|
|
123
|
+
return {
|
|
124
|
+
...baseNode,
|
|
125
|
+
kind: "llm",
|
|
126
|
+
action: {
|
|
127
|
+
provider: node.provider,
|
|
128
|
+
model: node.model,
|
|
129
|
+
prompt: node.prompt,
|
|
130
|
+
...(node.system ? { system: node.system } : {}),
|
|
131
|
+
...(node.temperature !== undefined ? { temperature: node.temperature } : {}),
|
|
132
|
+
...(node.maxTokens !== undefined ? { maxTokens: node.maxTokens } : {}),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
case "human":
|
|
136
|
+
return {
|
|
137
|
+
...baseNode,
|
|
138
|
+
kind: "human",
|
|
139
|
+
action: {
|
|
140
|
+
prompt: node.prompt,
|
|
141
|
+
interactionType: node.interactionType,
|
|
142
|
+
...(node.options ? { options: [...node.options] } : {}),
|
|
143
|
+
...(resolvedHumanTimeout !== undefined ? { timeout: resolvedHumanTimeout } : {}),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
case "condition":
|
|
147
|
+
return {
|
|
148
|
+
...baseNode,
|
|
149
|
+
kind: "condition",
|
|
150
|
+
action: {
|
|
151
|
+
conditionExpr: node.condition,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
case "merge":
|
|
155
|
+
return {
|
|
156
|
+
...baseNode,
|
|
157
|
+
kind: "merge",
|
|
158
|
+
action: {
|
|
159
|
+
join: {
|
|
160
|
+
waitFor: [...node.waitFor],
|
|
161
|
+
strategy: node.strategy ?? "all",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
case "subworkflow":
|
|
166
|
+
return {
|
|
167
|
+
...baseNode,
|
|
168
|
+
kind: "subworkflow",
|
|
169
|
+
action: {
|
|
170
|
+
specRef: node.specRef,
|
|
171
|
+
...(node.inputs ? { inputs: structuredClone(node.inputs as Record<string, unknown>) } : {}),
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function resolveNodeExecution(
|
|
178
|
+
globalExecutionPolicy: ExecutionPolicy | undefined,
|
|
179
|
+
nodeExecutionPolicy: ExecutionPolicy | undefined,
|
|
180
|
+
): {
|
|
181
|
+
execution?: CirExecutionPolicy;
|
|
182
|
+
failureRouting?: CirFailureRoutingHint[];
|
|
183
|
+
} {
|
|
184
|
+
if (!globalExecutionPolicy && !nodeExecutionPolicy) {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const mergedExecutionPolicy = {
|
|
189
|
+
...(globalExecutionPolicy ?? {}),
|
|
190
|
+
...(nodeExecutionPolicy ?? {}),
|
|
191
|
+
};
|
|
192
|
+
const { failureClassification, ...execution } = mergedExecutionPolicy;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
execution: Object.keys(execution).length > 0 ? execution : undefined,
|
|
196
|
+
failureRouting: failureClassification?.map(classification => ({ ...classification })),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function lowerVerification(rules: VerificationRule[] | undefined): CirVerificationHook[] | undefined {
|
|
201
|
+
if (!rules || rules.length === 0) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return rules.map(rule => ({
|
|
206
|
+
kind: rule.kind,
|
|
207
|
+
checkNodeId: rule.checkNodeId,
|
|
208
|
+
onFail: rule.onFail,
|
|
209
|
+
...(rule.maxAttempts !== undefined ? { maxAttempts: rule.maxAttempts } : {}),
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function cloneRetryPolicy(retryPolicy: TaskNode["retryPolicy"]): NonNullable<TaskNode["retryPolicy"]> {
|
|
214
|
+
return {
|
|
215
|
+
...retryPolicy,
|
|
216
|
+
...(retryPolicy.retryOn ? { retryOn: [...retryPolicy.retryOn] } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function lowerGlobalPolicies(spec: HarnessSpec): CirGlobalPolicies | undefined {
|
|
221
|
+
const globalPolicies: CirGlobalPolicies = {};
|
|
222
|
+
|
|
223
|
+
if (spec.executionPolicy) {
|
|
224
|
+
globalPolicies.execution = {
|
|
225
|
+
...spec.executionPolicy,
|
|
226
|
+
...(spec.executionPolicy.failureClassification
|
|
227
|
+
? {
|
|
228
|
+
failureClassification: spec.executionPolicy.failureClassification.map(classification => ({ ...classification })),
|
|
229
|
+
}
|
|
230
|
+
: {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (spec.humanPolicy) {
|
|
235
|
+
globalPolicies.human = {
|
|
236
|
+
...spec.humanPolicy,
|
|
237
|
+
...(spec.humanPolicy.notificationChannels
|
|
238
|
+
? { notificationChannels: [...spec.humanPolicy.notificationChannels] }
|
|
239
|
+
: {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (spec.observabilityPolicy) {
|
|
244
|
+
globalPolicies.observability = {
|
|
245
|
+
...spec.observabilityPolicy,
|
|
246
|
+
...(spec.observabilityPolicy.logDestinations
|
|
247
|
+
? { logDestinations: [...spec.observabilityPolicy.logDestinations] }
|
|
248
|
+
: {}),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return Object.keys(globalPolicies).length > 0 ? globalPolicies : undefined;
|
|
253
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { CirNode, CirTransition, CirWorkflow } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function optimizeCirWorkflow(cir: CirWorkflow): { optimized: CirWorkflow; passes: string[] } {
|
|
4
|
+
const passes: string[] = [];
|
|
5
|
+
|
|
6
|
+
let current = structuredClone(cir);
|
|
7
|
+
|
|
8
|
+
const afterDeadNode = eliminateDeadNodes(current);
|
|
9
|
+
if (afterDeadNode.changed) {
|
|
10
|
+
passes.push("dead-node-elimination");
|
|
11
|
+
current = afterDeadNode.workflow;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const afterMergeElision = elideSingleBranchMerges(current);
|
|
15
|
+
if (afterMergeElision.changed) {
|
|
16
|
+
passes.push("single-branch-merge-elision");
|
|
17
|
+
current = afterMergeElision.workflow;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const afterFusion = fuseAdjacentToolNodes(current);
|
|
21
|
+
if (afterFusion.changed) {
|
|
22
|
+
passes.push("adjacent-tool-node-fusion");
|
|
23
|
+
current = afterFusion.workflow;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { optimized: current, passes };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function eliminateDeadNodes(cir: CirWorkflow): { workflow: CirWorkflow; changed: boolean } {
|
|
30
|
+
const outgoingMap = new Map<string, CirTransition[]>();
|
|
31
|
+
for (const t of cir.transitions) {
|
|
32
|
+
const list = outgoingMap.get(t.from) ?? [];
|
|
33
|
+
list.push(t);
|
|
34
|
+
outgoingMap.set(t.from, list);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Collect verification nodes (same logic as validate.ts)
|
|
38
|
+
const verificationNodes = new Set<string>();
|
|
39
|
+
for (const node of cir.nodes) {
|
|
40
|
+
if (node.verification) {
|
|
41
|
+
for (const hook of node.verification) {
|
|
42
|
+
verificationNodes.add(hook.checkNodeId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const reachable = new Set<string>([cir.entryNodeId]);
|
|
48
|
+
const queue = [cir.entryNodeId];
|
|
49
|
+
while (queue.length > 0) {
|
|
50
|
+
const current = queue.shift()!;
|
|
51
|
+
for (const t of outgoingMap.get(current) ?? []) {
|
|
52
|
+
if (!reachable.has(t.to)) {
|
|
53
|
+
reachable.add(t.to);
|
|
54
|
+
queue.push(t.to);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Include verification hook targets and transitively reachable nodes
|
|
60
|
+
for (const id of verificationNodes) {
|
|
61
|
+
if (!reachable.has(id)) {
|
|
62
|
+
reachable.add(id);
|
|
63
|
+
queue.push(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
while (queue.length > 0) {
|
|
67
|
+
const current = queue.shift()!;
|
|
68
|
+
for (const t of outgoingMap.get(current) ?? []) {
|
|
69
|
+
if (!reachable.has(t.to)) {
|
|
70
|
+
reachable.add(t.to);
|
|
71
|
+
queue.push(t.to);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const filteredNodes = cir.nodes.filter(n => reachable.has(n.id));
|
|
77
|
+
const filteredTransitions = cir.transitions.filter(t => reachable.has(t.from) && reachable.has(t.to));
|
|
78
|
+
|
|
79
|
+
const changed = filteredNodes.length !== cir.nodes.length || filteredTransitions.length !== cir.transitions.length;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
workflow: { ...cir, nodes: filteredNodes, transitions: filteredTransitions },
|
|
83
|
+
changed,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function elideSingleBranchMerges(cir: CirWorkflow): { workflow: CirWorkflow; changed: boolean } {
|
|
88
|
+
const incomingMap = new Map<string, CirTransition[]>();
|
|
89
|
+
for (const t of cir.transitions) {
|
|
90
|
+
const list = incomingMap.get(t.to) ?? [];
|
|
91
|
+
list.push(t);
|
|
92
|
+
incomingMap.set(t.to, list);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const outgoingMap = new Map<string, CirTransition[]>();
|
|
96
|
+
for (const t of cir.transitions) {
|
|
97
|
+
const list = outgoingMap.get(t.from) ?? [];
|
|
98
|
+
list.push(t);
|
|
99
|
+
outgoingMap.set(t.from, list);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mergesToElide = new Set<string>();
|
|
103
|
+
|
|
104
|
+
for (const node of cir.nodes) {
|
|
105
|
+
if (node.kind !== "merge") continue;
|
|
106
|
+
if (node.action.join.waitFor.length !== 1) continue;
|
|
107
|
+
|
|
108
|
+
const incoming = (incomingMap.get(node.id) ?? []).filter(t => t.when === "success");
|
|
109
|
+
if (incoming.length !== 1) continue;
|
|
110
|
+
if (incoming[0].from !== node.action.join.waitFor[0]) continue;
|
|
111
|
+
|
|
112
|
+
mergesToElide.add(node.id);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (mergesToElide.size === 0) {
|
|
116
|
+
return { workflow: cir, changed: false };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const newNodes = cir.nodes.filter(n => !mergesToElide.has(n.id));
|
|
120
|
+
const newTransitions: CirTransition[] = [];
|
|
121
|
+
|
|
122
|
+
for (const t of cir.transitions) {
|
|
123
|
+
if (mergesToElide.has(t.from)) {
|
|
124
|
+
const mergeOutgoing = outgoingMap.get(t.from) ?? [];
|
|
125
|
+
const mergeIncoming = (incomingMap.get(t.from) ?? []).filter(inc => inc.when === "success");
|
|
126
|
+
for (const inc of mergeIncoming) {
|
|
127
|
+
for (const out of mergeOutgoing) {
|
|
128
|
+
newTransitions.push({
|
|
129
|
+
from: inc.from,
|
|
130
|
+
to: out.to,
|
|
131
|
+
when: out.when,
|
|
132
|
+
source: inc.source,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (mergesToElide.has(t.to)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
newTransitions.push(t);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { workflow: { ...cir, nodes: newNodes, transitions: newTransitions }, changed: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fuseAdjacentToolNodes(cir: CirWorkflow): { workflow: CirWorkflow; changed: boolean } {
|
|
150
|
+
const outgoingMap = new Map<string, CirTransition[]>();
|
|
151
|
+
for (const t of cir.transitions) {
|
|
152
|
+
const list = outgoingMap.get(t.from) ?? [];
|
|
153
|
+
list.push(t);
|
|
154
|
+
outgoingMap.set(t.from, list);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const incomingMap = new Map<string, CirTransition[]>();
|
|
158
|
+
for (const t of cir.transitions) {
|
|
159
|
+
const list = incomingMap.get(t.to) ?? [];
|
|
160
|
+
list.push(t);
|
|
161
|
+
incomingMap.set(t.to, list);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const nodeMap = new Map<string, CirNode>();
|
|
165
|
+
for (const n of cir.nodes) {
|
|
166
|
+
nodeMap.set(n.id, n);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const fusedAway = new Set<string>();
|
|
170
|
+
const redirects = new Map<string, string>();
|
|
171
|
+
let changed = false;
|
|
172
|
+
|
|
173
|
+
for (const node of cir.nodes) {
|
|
174
|
+
if (node.kind !== "tool") continue;
|
|
175
|
+
if (fusedAway.has(node.id)) continue;
|
|
176
|
+
|
|
177
|
+
const outgoing = (outgoingMap.get(node.id) ?? []).filter(t => t.when === "success");
|
|
178
|
+
if (outgoing.length !== 1) continue;
|
|
179
|
+
|
|
180
|
+
const nextNode = nodeMap.get(outgoing[0].to);
|
|
181
|
+
if (!nextNode || nextNode.kind !== "tool") continue;
|
|
182
|
+
if (fusedAway.has(nextNode.id)) continue;
|
|
183
|
+
|
|
184
|
+
if (node.action.tool !== nextNode.action.tool) continue;
|
|
185
|
+
if (node.action.tool !== "bash" && node.action.tool !== "sh") continue;
|
|
186
|
+
if (node.action.env !== nextNode.action.env) continue;
|
|
187
|
+
if (node.retry || nextNode.retry) continue;
|
|
188
|
+
if (node.verification || nextNode.verification) continue;
|
|
189
|
+
|
|
190
|
+
const mergedArgs = [node.action.args.join(" && ") + " && " + nextNode.action.args.join(" && ")];
|
|
191
|
+
const mergedNode: CirNode = {
|
|
192
|
+
...node,
|
|
193
|
+
action: {
|
|
194
|
+
...node.action,
|
|
195
|
+
args: mergedArgs,
|
|
196
|
+
},
|
|
197
|
+
retry: nextNode.retry,
|
|
198
|
+
verification: nextNode.verification,
|
|
199
|
+
failureRouting: nextNode.failureRouting,
|
|
200
|
+
terminal: nextNode.terminal,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
nodeMap.set(node.id, mergedNode);
|
|
204
|
+
fusedAway.add(nextNode.id);
|
|
205
|
+
redirects.set(nextNode.id, node.id);
|
|
206
|
+
changed = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!changed) {
|
|
210
|
+
return { workflow: cir, changed: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const newNodes = cir.nodes
|
|
214
|
+
.filter(n => !fusedAway.has(n.id))
|
|
215
|
+
.map(n => {
|
|
216
|
+
const updated = nodeMap.get(n.id) ?? n;
|
|
217
|
+
if (updated.kind === "merge") {
|
|
218
|
+
const newWaitFor = updated.action.join.waitFor.map(id => redirects.get(id) ?? id);
|
|
219
|
+
if (newWaitFor.some((id, i) => id !== updated.action.join.waitFor[i])) {
|
|
220
|
+
return {
|
|
221
|
+
...updated,
|
|
222
|
+
action: {
|
|
223
|
+
...updated.action,
|
|
224
|
+
join: { ...updated.action.join, waitFor: newWaitFor },
|
|
225
|
+
},
|
|
226
|
+
} as CirNode;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return updated;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const newTransitions = cir.transitions
|
|
233
|
+
.filter(t => !fusedAway.has(t.from) && !fusedAway.has(t.to))
|
|
234
|
+
.map(t => ({
|
|
235
|
+
...t,
|
|
236
|
+
from: redirects.get(t.from) ?? t.from,
|
|
237
|
+
to: redirects.get(t.to) ?? t.to,
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
const dedupedTransitions: CirTransition[] = [];
|
|
241
|
+
const seenKeys = new Set<string>();
|
|
242
|
+
for (const t of newTransitions) {
|
|
243
|
+
const key = `${t.from}:${t.when}:${t.to}`;
|
|
244
|
+
if (!seenKeys.has(key)) {
|
|
245
|
+
seenKeys.add(key);
|
|
246
|
+
dedupedTransitions.push(t);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { workflow: { ...cir, nodes: newNodes, transitions: dedupedTransitions }, changed: true };
|
|
251
|
+
}
|